beniya 0.6.0 → 0.6.1

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.
@@ -1,25 +1,51 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'shellwords'
4
- require_relative 'bookmark'
5
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'
6
12
 
7
13
  module Beniya
8
14
  class KeybindHandler
9
- attr_reader :current_index, :filter_query
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
10
32
 
11
33
  def initialize
12
34
  @current_index = 0
13
35
  @directory_listing = nil
14
36
  @terminal_ui = nil
15
37
  @file_opener = FileOpener.new
16
- @filter_mode = false
17
- @filter_query = ''
18
- @filtered_entries = []
19
- @original_entries = []
20
- @selected_items = []
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
21
48
  @base_directory = nil
22
- @bookmark = Bookmark.new
23
49
  end
24
50
 
25
51
  def set_directory_listing(directory_listing)
@@ -36,18 +62,18 @@ module Beniya
36
62
  end
37
63
 
38
64
  def selected_items
39
- @selected_items.dup
65
+ @selection_manager.selected_items
40
66
  end
41
67
 
42
68
  def is_selected?(entry_name)
43
- @selected_items.include?(entry_name)
69
+ @selection_manager.selected?(entry_name)
44
70
  end
45
71
 
46
72
  def handle_key(key)
47
73
  return false unless @directory_listing
48
74
 
49
75
  # フィルターモード中は他のキーバインドを無効化
50
- return handle_filter_input(key) if @filter_mode
76
+ return handle_filter_input(key) if @filter_manager.filter_mode
51
77
 
52
78
  case key
53
79
  when 'j'
@@ -69,10 +95,9 @@ module Beniya
69
95
  when 'e' # e - open directory in file explorer
70
96
  open_directory_in_explorer
71
97
  when 'f' # f - filter files
72
- if !@filter_query.empty?
98
+ if @filter_manager.filter_active?
73
99
  # フィルタが設定されている場合は再編集モードに入る
74
- @filter_mode = true
75
- @original_entries = @directory_listing.list_entries.dup if @original_entries.empty?
100
+ @filter_manager.restart_filter_mode(@directory_listing.list_entries)
76
101
  else
77
102
  # 新規フィルターモード開始
78
103
  start_filter_mode
@@ -80,7 +105,7 @@ module Beniya
80
105
  when ' ' # Space - toggle selection
81
106
  toggle_selection
82
107
  when "\e" # ESC
83
- if !@filter_query.empty?
108
+ if @filter_manager.filter_active?
84
109
  # フィルタが設定されている場合はクリア
85
110
  clear_filter_mode
86
111
  true
@@ -127,12 +152,12 @@ module Beniya
127
152
  end
128
153
 
129
154
  def filter_active?
130
- @filter_mode || !@filter_query.empty?
155
+ @filter_manager.filter_active?
131
156
  end
132
157
 
133
158
  def get_active_entries
134
- if @filter_mode || !@filter_query.empty?
135
- @filtered_entries.empty? ? [] : @filtered_entries
159
+ if @filter_manager.filter_active?
160
+ @filter_manager.filtered_entries
136
161
  else
137
162
  @directory_listing&.list_entries || []
138
163
  end
@@ -193,10 +218,9 @@ module Beniya
193
218
  @terminal_ui&.refresh_display
194
219
 
195
220
  @directory_listing.refresh
196
- if @filter_mode || !@filter_query.empty?
221
+ if @filter_manager.filter_active?
197
222
  # Re-apply filter with new directory contents
198
- @original_entries = @directory_listing.list_entries.dup
199
- apply_filter
223
+ @filter_manager.update_entries(@directory_listing.list_entries)
200
224
  else
201
225
  # adjust index to stay within bounds after refresh
202
226
  entries = @directory_listing.list_entries
@@ -233,12 +257,16 @@ module Beniya
233
257
  current_path = @directory_listing&.current_path || Dir.pwd
234
258
 
235
259
  # fzfでファイル検索を実行
236
- selected_file = `cd "#{current_path}" && find . -type f | fzf --preview 'cat {}'`.strip
260
+ # Dir.chdirを使用してディレクトリ移動を安全に行う
261
+ selected_file = nil
262
+ Dir.chdir(current_path) do
263
+ selected_file = `find . -type f | fzf --preview 'cat {}'`.strip
264
+ end
237
265
 
238
266
  # ファイルが選択された場合、そのファイルを開く
239
- if !selected_file.empty? && File.exist?(File.join(current_path, selected_file))
267
+ if !selected_file.empty?
240
268
  full_path = File.expand_path(selected_file, current_path)
241
- @file_opener.open_file(full_path)
269
+ @file_opener.open_file(full_path) if File.exist?(full_path)
242
270
  end
243
271
 
244
272
  true
@@ -259,7 +287,13 @@ module Beniya
259
287
  return false if search_query.empty?
260
288
 
261
289
  # execute rga file content search
262
- search_results = `cd "#{current_path}" && rga --line-number --with-filename "#{search_query}" . 2>/dev/null`
290
+ # Dir.chdirを使用してディレクトリ移動を安全に行う
291
+ search_results = nil
292
+ Dir.chdir(current_path) do
293
+ # Shellwords.escapeで検索クエリをエスケープ
294
+ escaped_query = Shellwords.escape(search_query)
295
+ search_results = `rga --line-number --with-filename #{escaped_query} . 2>/dev/null`
296
+ end
263
297
 
264
298
  if search_results.empty?
265
299
  puts "\n#{ConfigLoader.message('keybind.no_matches')}"
@@ -292,65 +326,36 @@ module Beniya
292
326
  end
293
327
 
294
328
  def start_filter_mode
295
- @filter_mode = true
296
- @filter_query = ''
297
- @original_entries = @directory_listing.list_entries.dup
298
- @filtered_entries = @original_entries.dup
329
+ @filter_manager.start_filter_mode(@directory_listing.list_entries)
299
330
  @current_index = 0
300
331
  true
301
332
  end
302
333
 
303
334
  def handle_filter_input(key)
304
- case key
305
- when "\e" # ESC - フィルタをクリアして通常モードに戻る
335
+ result = @filter_manager.handle_filter_input(key)
336
+
337
+ case result
338
+ when :exit_clear
306
339
  clear_filter_mode
307
- when "\r", "\n" # Enter - フィルタを維持して通常モードに戻る
340
+ when :exit_keep
308
341
  exit_filter_mode_keep_filter
309
- when "\u007f", "\b" # Backspace
310
- if @filter_query.length > 0
311
- @filter_query = @filter_query[0...-1]
312
- apply_filter
313
- else
314
- clear_filter_mode
315
- end
316
- else
317
- # printable characters (英数字、記号、日本語文字など)
318
- if key.length == 1 && key.ord >= 32 && key.ord < 127 # ASCII printable
319
- @filter_query += key
320
- apply_filter
321
- elsif key.bytesize > 1 # Multi-byte characters (Japanese, etc.)
322
- @filter_query += key
323
- apply_filter
324
- end
325
- # その他のキー(Ctrl+c等)は無視
342
+ when :backspace_exit
343
+ clear_filter_mode
344
+ when :continue
345
+ @current_index = [@current_index, [@filter_manager.filtered_entries.length - 1, 0].max].min
326
346
  end
327
- true
328
- end
329
347
 
330
- def apply_filter
331
- if @filter_query.empty?
332
- @filtered_entries = @original_entries.dup
333
- else
334
- query_downcase = @filter_query.downcase
335
- @filtered_entries = @original_entries.select do |entry|
336
- entry[:name].downcase.include?(query_downcase)
337
- end
338
- end
339
- @current_index = [@current_index, [@filtered_entries.length - 1, 0].max].min
348
+ true
340
349
  end
341
350
 
342
351
  def exit_filter_mode_keep_filter
343
352
  # フィルタを維持したまま通常モードに戻る
344
- @filter_mode = false
345
- # @filter_query, @filtered_entries は維持
353
+ @filter_manager.exit_filter_mode_keep_filter
346
354
  end
347
355
 
348
356
  def clear_filter_mode
349
357
  # フィルタをクリアして通常モードに戻る
350
- @filter_mode = false
351
- @filter_query = ''
352
- @filtered_entries = []
353
- @original_entries = []
358
+ @filter_manager.clear_filter
354
359
  @current_index = 0
355
360
  end
356
361
 
@@ -367,46 +372,24 @@ module Beniya
367
372
  filename = STDIN.gets.chomp
368
373
  return false if filename.empty?
369
374
 
370
- # 不正なファイル名のチェック
371
- if filename.include?('/') || filename.include?('\\')
372
- puts "\n#{ConfigLoader.message('keybind.invalid_filename')}"
373
- print ConfigLoader.message('keybind.press_any_key')
374
- STDIN.getch
375
- return false
376
- end
377
-
378
- file_path = File.join(current_path, filename)
379
-
380
- # ファイルが既に存在する場合の確認
381
- if File.exist?(file_path)
382
- puts "\n#{ConfigLoader.message('keybind.file_exists')}"
383
- print ConfigLoader.message('keybind.press_any_key')
384
- STDIN.getch
385
- return false
386
- end
387
-
388
- begin
389
- # ファイルを作成
390
- File.write(file_path, '')
375
+ # FileOperationsを使用してファイルを作成
376
+ result = @file_operations.create_file(current_path, filename)
391
377
 
392
- # ディレクトリ表示を更新
378
+ # ディレクトリ表示を更新
379
+ if result.success
393
380
  @directory_listing.refresh
394
381
 
395
382
  # 作成したファイルを選択状態にする
396
383
  entries = @directory_listing.list_entries
397
384
  new_file_index = entries.find_index { |entry| entry[:name] == filename }
398
385
  @current_index = new_file_index if new_file_index
399
-
400
- puts "\n#{ConfigLoader.message('keybind.file_created')}: #{filename}"
401
- print ConfigLoader.message('keybind.press_any_key')
402
- STDIN.getch
403
- true
404
- rescue StandardError => e
405
- puts "\n#{ConfigLoader.message('keybind.creation_error')}: #{e.message}"
406
- print ConfigLoader.message('keybind.press_any_key')
407
- STDIN.getch
408
- false
409
386
  end
387
+
388
+ # 結果を表示
389
+ puts "\n#{result.message}"
390
+ print ConfigLoader.message('keybind.press_any_key')
391
+ STDIN.getch
392
+ result.success
410
393
  end
411
394
 
412
395
  def create_directory
@@ -417,75 +400,63 @@ module Beniya
417
400
  dirname = STDIN.gets.chomp
418
401
  return false if dirname.empty?
419
402
 
420
- # 不正なディレクトリ名のチェック
421
- if dirname.include?('/') || dirname.include?('\\')
422
- puts "\n#{ConfigLoader.message('keybind.invalid_dirname')}"
423
- print ConfigLoader.message('keybind.press_any_key')
424
- STDIN.getch
425
- return false
426
- end
427
-
428
- dir_path = File.join(current_path, dirname)
403
+ # FileOperationsを使用してディレクトリを作成
404
+ result = @file_operations.create_directory(current_path, dirname)
429
405
 
430
- # ディレクトリが既に存在する場合の確認
431
- if File.exist?(dir_path)
432
- puts "\n#{ConfigLoader.message('keybind.directory_exists')}"
433
- print ConfigLoader.message('keybind.press_any_key')
434
- STDIN.getch
435
- return false
436
- end
437
-
438
- begin
439
- # ディレクトリを作成
440
- Dir.mkdir(dir_path)
441
-
442
- # ディレクトリ表示を更新
406
+ # ディレクトリ表示を更新
407
+ if result.success
443
408
  @directory_listing.refresh
444
409
 
445
410
  # 作成したディレクトリを選択状態にする
446
411
  entries = @directory_listing.list_entries
447
412
  new_dir_index = entries.find_index { |entry| entry[:name] == dirname }
448
413
  @current_index = new_dir_index if new_dir_index
449
-
450
- puts "\n#{ConfigLoader.message('keybind.directory_created')}: #{dirname}"
451
- print ConfigLoader.message('keybind.press_any_key')
452
- STDIN.getch
453
- true
454
- rescue StandardError => e
455
- puts "\n#{ConfigLoader.message('keybind.creation_error')}: #{e.message}"
456
- print ConfigLoader.message('keybind.press_any_key')
457
- STDIN.getch
458
- false
459
414
  end
415
+
416
+ # 結果を表示
417
+ puts "\n#{result.message}"
418
+ print ConfigLoader.message('keybind.press_any_key')
419
+ STDIN.getch
420
+ result.success
460
421
  end
461
422
 
462
423
  def toggle_selection
463
424
  entry = current_entry
464
425
  return false unless entry
465
426
 
466
- if @selected_items.include?(entry[:name])
467
- @selected_items.delete(entry[:name])
468
- else
469
- @selected_items << entry[:name]
470
- end
427
+ @selection_manager.toggle_selection(entry)
471
428
  true
472
429
  end
473
430
 
474
431
  def move_selected_to_base
475
- return false if @selected_items.empty? || @base_directory.nil?
432
+ return false if @selection_manager.empty? || @base_directory.nil?
433
+
434
+ if show_confirmation_dialog('Move', @selection_manager.count)
435
+ current_path = @directory_listing&.current_path || Dir.pwd
436
+ result = @file_operations.move(@selection_manager.selected_items, current_path, @base_directory)
476
437
 
477
- if show_confirmation_dialog('Move', @selected_items.length)
478
- perform_file_operation(:move, @selected_items, @base_directory)
438
+ # Show result and refresh
439
+ show_operation_result(result)
440
+ @selection_manager.clear
441
+ @directory_listing.refresh if @directory_listing
442
+ true
479
443
  else
480
444
  false
481
445
  end
482
446
  end
483
447
 
484
448
  def copy_selected_to_base
485
- return false if @selected_items.empty? || @base_directory.nil?
449
+ return false if @selection_manager.empty? || @base_directory.nil?
450
+
451
+ if show_confirmation_dialog('Copy', @selection_manager.count)
452
+ current_path = @directory_listing&.current_path || Dir.pwd
453
+ result = @file_operations.copy(@selection_manager.selected_items, current_path, @base_directory)
486
454
 
487
- if show_confirmation_dialog('Copy', @selected_items.length)
488
- perform_file_operation(:copy, @selected_items, @base_directory)
455
+ # Show result and refresh
456
+ show_operation_result(result)
457
+ @selection_manager.clear
458
+ @directory_listing.refresh if @directory_listing
459
+ true
489
460
  else
490
461
  false
491
462
  end
@@ -497,54 +468,30 @@ module Beniya
497
468
  %w[y yes].include?(response)
498
469
  end
499
470
 
500
- def perform_file_operation(operation, items, destination)
501
- success_count = 0
502
- current_path = @directory_listing&.current_path || Dir.pwd
503
-
504
- items.each do |item_name|
505
- source_path = File.join(current_path, item_name)
506
- dest_path = File.join(destination, item_name)
507
-
508
- begin
509
- case operation
510
- when :move
511
- if File.exist?(dest_path)
512
- puts "\n#{item_name} already exists in destination. Skipping."
513
- next
514
- end
515
- FileUtils.mv(source_path, dest_path)
516
- when :copy
517
- if File.exist?(dest_path)
518
- puts "\n#{item_name} already exists in destination. Skipping."
519
- next
520
- end
521
- if File.directory?(source_path)
522
- FileUtils.cp_r(source_path, dest_path)
523
- else
524
- FileUtils.cp(source_path, dest_path)
525
- end
526
- end
527
- success_count += 1
528
- rescue StandardError => e
529
- puts "\nFailed to #{operation == :move ? 'move' : 'copy'} #{item_name}: #{e.message}"
530
- end
471
+ # Helper method to show operation result
472
+ def show_operation_result(result)
473
+ if result.errors.any?
474
+ puts "\n#{result.message}"
475
+ result.errors.each { |error| puts " - #{error}" }
476
+ else
477
+ puts "\n#{result.message}"
531
478
  end
532
-
533
- # 操作完了後の処理
534
- @selected_items.clear
535
- @directory_listing.refresh if @directory_listing
536
-
537
- puts "\n#{operation == :move ? 'Moved' : 'Copied'} #{success_count} item(s)."
538
479
  print 'Press any key to continue...'
539
480
  STDIN.getch
540
- true
541
481
  end
542
482
 
543
483
  def delete_selected_files
544
- return false if @selected_items.empty?
484
+ return false if @selection_manager.empty?
485
+
486
+ if show_delete_confirmation(@selection_manager.count)
487
+ current_path = @directory_listing&.current_path || Dir.pwd
488
+ result = @file_operations.delete(@selection_manager.selected_items, current_path)
545
489
 
546
- if show_delete_confirmation(@selected_items.length)
547
- perform_delete_operation(@selected_items)
490
+ # Show detailed delete result
491
+ show_deletion_result(result.count, @selection_manager.count, result.errors)
492
+ @selection_manager.clear
493
+ @directory_listing.refresh if @directory_listing
494
+ true
548
495
  else
549
496
  false
550
497
  end
@@ -567,15 +514,15 @@ module Beniya
567
514
  ]
568
515
 
569
516
  # ダイアログのサイズ設定(コンテンツに合わせて調整)
570
- dialog_width = 45
571
- # タイトルあり: 上枠1 + タイトル1 + 区切り1 + コンテンツ6 + 下枠1 = 10
572
- dialog_height = 4 + content_lines.length
517
+ dialog_width = CONFIRMATION_DIALOG_WIDTH
518
+ # タイトルあり: 上枠1 + タイトル1 + 区切り1 + コンテンツ + 下枠1
519
+ dialog_height = DIALOG_BORDER_HEIGHT + content_lines.length
573
520
 
574
521
  # ダイアログの位置を中央に設定
575
- x, y = get_screen_center(dialog_width, dialog_height)
522
+ x, y = @dialog_renderer.calculate_center(dialog_width, dialog_height)
576
523
 
577
524
  # ダイアログの描画
578
- draw_floating_window(x, y, dialog_width, dialog_height, title, content_lines, {
525
+ @dialog_renderer.draw_floating_window(x, y, dialog_width, dialog_height, title, content_lines, {
579
526
  border_color: "\e[31m", # 赤色(警告)
580
527
  title_color: "\e[1;31m", # 太字赤色
581
528
  content_color: "\e[37m" # 白色
@@ -591,16 +538,16 @@ module Beniya
591
538
  case input
592
539
  when 'y'
593
540
  # ダイアログをクリア
594
- clear_floating_window_area(x, y, dialog_width, dialog_height)
541
+ @dialog_renderer.clear_area(x, y, dialog_width, dialog_height)
595
542
  @terminal_ui&.refresh_display # 画面を再描画
596
543
  return true
597
544
  when 'n', "\e", "\x03" # n, ESC, Ctrl+C
598
545
  # ダイアログをクリア
599
- clear_floating_window_area(x, y, dialog_width, dialog_height)
546
+ @dialog_renderer.clear_area(x, y, dialog_width, dialog_height)
600
547
  @terminal_ui&.refresh_display # 画面を再描画
601
548
  return false
602
549
  when 'q' # qキーでもキャンセル
603
- clear_floating_window_area(x, y, dialog_width, dialog_height)
550
+ @dialog_renderer.clear_area(x, y, dialog_width, dialog_height)
604
551
  @terminal_ui&.refresh_display
605
552
  return false
606
553
  end
@@ -609,100 +556,73 @@ module Beniya
609
556
  end
610
557
 
611
558
  def perform_delete_operation(items)
559
+ Logger.debug('Starting delete operation', context: { items: items, count: items.length })
560
+
612
561
  success_count = 0
613
562
  error_messages = []
614
563
  current_path = @directory_listing&.current_path || Dir.pwd
615
- debug_log = []
616
564
 
617
565
  items.each do |item_name|
618
566
  item_path = File.join(current_path, item_name)
619
- debug_log << "Processing: #{item_name}"
567
+ Logger.debug("Processing deletion", context: { item: item_name, path: item_path })
620
568
 
621
569
  begin
622
570
  # ファイル/ディレクトリの存在確認
623
571
  unless File.exist?(item_path)
624
572
  error_messages << "#{item_name}: File not found"
625
- debug_log << ' Error: File not found'
573
+ Logger.warn("File not found for deletion", context: { item: item_name })
626
574
  next
627
575
  end
628
576
 
629
- debug_log << ' Existence check: OK'
630
577
  is_directory = File.directory?(item_path)
631
- debug_log << " Type: #{is_directory ? 'Directory' : 'File'}"
578
+ Logger.debug("Item type determined", context: { item: item_name, type: is_directory ? 'Directory' : 'File' })
632
579
 
633
580
  if is_directory
634
581
  FileUtils.rm_rf(item_path)
635
- debug_log << ' FileUtils.rm_rf executed'
636
582
  else
637
583
  FileUtils.rm(item_path)
638
- debug_log << ' FileUtils.rm executed'
639
584
  end
640
585
 
641
586
  # 削除が実際に成功したかを確認
642
- sleep(0.01) # 10ms待機してファイルシステムの同期を待つ
587
+ sleep(FILESYSTEM_SYNC_DELAY) # wait for filesystem sync
643
588
  still_exists = File.exist?(item_path)
644
- debug_log << " Post-deletion check: #{still_exists}"
645
589
 
646
590
  if still_exists
647
591
  error_messages << "#{item_name}: Deletion failed"
648
- debug_log << ' Result: Failed'
592
+ Logger.error("Deletion failed", context: { item: item_name, still_exists: true })
649
593
  else
650
594
  success_count += 1
651
- debug_log << ' Result: Success'
595
+ Logger.debug("Deletion successful", context: { item: item_name })
652
596
  end
653
597
  rescue StandardError => e
654
598
  error_messages << "#{item_name}: #{e.message}"
655
- debug_log << " Exception: #{e.message}"
599
+ Logger.error("Exception during deletion", exception: e, context: { item: item_name })
656
600
  end
657
601
  end
658
602
 
659
- # デバッグログをファイルに出力(開発時のみ)
660
- if ENV['BENIYA_DEBUG'] == '1'
661
- debug_file = File.join(Dir.home, '.beniya_delete_debug.log')
662
- File.open(debug_file, 'a') do |f|
663
- f.puts "=== Delete Process Debug #{Time.now} ==="
664
- f.puts "Target directory: #{current_path}"
665
- f.puts "Target items: #{items.inspect}"
666
- debug_log.each { |line| f.puts line }
667
- f.puts "Final result: #{success_count} successful, #{items.length - success_count} failed"
668
- f.puts "Error messages: #{error_messages.inspect}"
669
- f.puts ''
670
- end
671
- end
672
-
673
-
674
- # デバッグ用:削除結果の値をログファイルに出力
675
- result_debug_file = File.join(Dir.home, '.beniya_result_debug.log')
676
- File.open(result_debug_file, 'a') do |f|
677
- f.puts "=== Delete Result Debug #{Time.now} ==="
678
- f.puts "success_count: #{success_count}"
679
- f.puts "total_count: #{items.length}"
680
- f.puts "error_messages.length: #{error_messages.length}"
681
- f.puts "has_errors: #{!error_messages.empty?}"
682
- f.puts "condition check: success_count == total_count && !has_errors = #{success_count == items.length && error_messages.empty?}"
683
- f.puts ""
684
- end
603
+ Logger.debug('Delete operation completed', context: {
604
+ success_count: success_count,
605
+ total_count: items.length,
606
+ error_count: error_messages.length,
607
+ has_errors: !error_messages.empty?
608
+ })
685
609
 
686
610
  # 削除結果をフローティングウィンドウで表示
687
611
  show_deletion_result(success_count, items.length, error_messages)
688
612
 
689
613
  # 削除完了後の処理
690
- @selected_items.clear
614
+ @selection_manager.clear
691
615
  @directory_listing.refresh if @directory_listing
692
616
 
693
617
  true
694
618
  end
695
619
 
696
620
  def show_deletion_result(success_count, total_count, error_messages = [])
697
- # 詳細デバッグログを出力
698
- detailed_debug_file = File.join(Dir.home, '.beniya_detailed_debug.log')
699
- File.open(detailed_debug_file, 'a') do |f|
700
- f.puts "=== show_deletion_result called #{Time.now} ==="
701
- f.puts "Arguments: success_count=#{success_count}, total_count=#{total_count}"
702
- f.puts "error_messages: #{error_messages.inspect}"
703
- f.puts "error_messages.empty?: #{error_messages.empty?}"
704
- f.puts ""
705
- end
621
+ Logger.debug('Showing deletion result dialog', context: {
622
+ success_count: success_count,
623
+ total_count: total_count,
624
+ error_messages: error_messages
625
+ })
706
626
 
707
627
  # エラーメッセージがある場合はダイアログサイズを拡大
708
628
  has_errors = !error_messages.empty?
@@ -710,10 +630,9 @@ module Beniya
710
630
  dialog_height = has_errors ? [8 + error_messages.length, 15].min : 6
711
631
 
712
632
  # ダイアログの位置を中央に設定
713
- x, y = get_screen_center(dialog_width, dialog_height)
633
+ x, y = @dialog_renderer.calculate_center(dialog_width, dialog_height)
714
634
 
715
635
  # 成功・失敗に応じた色設定
716
- # デバッグ: success_count == total_count かつ has_errors が false の場合のみ成功扱い
717
636
  if success_count == total_count && !has_errors
718
637
  border_color = "\e[32m" # 緑色(成功)
719
638
  title_color = "\e[1;32m" # 太字緑色
@@ -735,10 +654,6 @@ module Beniya
735
654
  # コンテンツの準備
736
655
  content_lines = ['', message]
737
656
 
738
- # デバッグ情報を追加(開発中のみ)
739
- content_lines << ""
740
- content_lines << "DEBUG: success=#{success_count}, total=#{total_count}, errors=#{error_messages.length}"
741
-
742
657
  # エラーメッセージがある場合は追加
743
658
  if has_errors
744
659
  content_lines << ''
@@ -750,7 +665,7 @@ module Beniya
750
665
  content_lines << 'Press any key to continue...'
751
666
 
752
667
  # ダイアログの描画
753
- draw_floating_window(x, y, dialog_width, dialog_height, title, content_lines, {
668
+ @dialog_renderer.draw_floating_window(x, y, dialog_width, dialog_height, title, content_lines, {
754
669
  border_color: border_color,
755
670
  title_color: title_color,
756
671
  content_color: "\e[37m"
@@ -760,469 +675,94 @@ module Beniya
760
675
  STDIN.getch
761
676
 
762
677
  # ダイアログをクリア
763
- clear_floating_window_area(x, y, dialog_width, dialog_height)
678
+ @dialog_renderer.clear_area(x, y, dialog_width, dialog_height)
764
679
  @terminal_ui&.refresh_display
765
680
  end
766
681
 
767
- # フローティングウィンドウの基盤メソッド
768
- def draw_floating_window(x, y, width, height, title, content_lines, options = {})
769
- # デフォルトオプション
770
- border_color = options[:border_color] || "\e[37m" # 白色
771
- title_color = options[:title_color] || "\e[1;33m" # 黄色(太字)
772
- content_color = options[:content_color] || "\e[37m" # 白色
773
- reset_color = "\e[0m"
774
-
775
- # ウィンドウの描画
776
- # 上辺
777
- print "\e[#{y};#{x}H#{border_color}┌#{'─' * (width - 2)}┐#{reset_color}"
778
-
779
- # タイトル行
780
- if title
781
- title_width = display_width(title)
782
- title_padding = (width - 2 - title_width) / 2
783
- padded_title = ' ' * title_padding + title
784
- title_line = pad_string_to_width(padded_title, width - 2)
785
- print "\e[#{y + 1};#{x}H#{border_color}│#{title_color}#{title_line}#{border_color}│#{reset_color}"
786
-
787
- # タイトル区切り線
788
- print "\e[#{y + 2};#{x}H#{border_color}├#{'─' * (width - 2)}┤#{reset_color}"
789
- content_start_y = y + 3
790
- else
791
- content_start_y = y + 1
792
- end
793
-
794
- # コンテンツ行
795
- content_height = title ? height - 4 : height - 2
796
- content_lines.each_with_index do |line, index|
797
- break if index >= content_height
798
-
799
- line_y = content_start_y + index
800
- line_content = pad_string_to_width(line, width - 2) # 正確な幅でパディング
801
- print "\e[#{line_y};#{x}H#{border_color}│#{content_color}#{line_content}#{border_color}│#{reset_color}"
802
- end
803
-
804
- # 空行を埋める
805
- remaining_lines = content_height - content_lines.length
806
- remaining_lines.times do |i|
807
- line_y = content_start_y + content_lines.length + i
808
- empty_line = ' ' * (width - 2)
809
- print "\e[#{line_y};#{x}H#{border_color}│#{empty_line}│#{reset_color}"
810
- end
811
-
812
- # 下辺
813
- bottom_y = y + height - 1
814
- print "\e[#{bottom_y};#{x}H#{border_color}└#{'─' * (width - 2)}┘#{reset_color}"
815
- end
816
-
817
- def display_width(str)
818
- # 日本語文字の幅を考慮した文字列幅の計算
819
- # Unicode East Asian Width プロパティを考慮
820
- str.each_char.map do |char|
821
- case char
822
- when /[\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF\uFF00-\uFFEF]/
823
- # 日本語の文字(ひらがな、カタカナ、漢字、全角記号)
824
- 2
825
- when /[\u0020-\u007E]/
826
- # ASCII文字
827
- 1
828
- else
829
- # その他の文字はバイト数で判断
830
- char.bytesize > 1 ? 2 : 1
831
- end
832
- end.sum
833
- end
834
-
835
- def pad_string_to_width(str, target_width)
836
- # 文字列を指定した表示幅になるようにパディング
837
- current_width = display_width(str)
838
- if current_width >= target_width
839
- # 文字列が長すぎる場合は切り詰め
840
- truncate_to_width(str, target_width)
841
- else
842
- # 不足分をスペースで埋める
843
- str + ' ' * (target_width - current_width)
844
- end
845
- end
846
-
847
- def truncate_to_width(str, max_width)
848
- # 指定した表示幅に収まるように文字列を切り詰め
849
- result = ''
850
- current_width = 0
851
-
852
- str.each_char do |char|
853
- char_width = display_width(char)
854
- break if current_width + char_width > max_width
855
-
856
- result += char
857
- current_width += char_width
858
- end
859
-
860
- result
861
- end
862
-
863
- def get_screen_center(content_width, content_height)
864
- # ターミナルのサイズを取得
865
- console = IO.console
866
- if console
867
- screen_width, screen_height = console.winsize.reverse
868
- else
869
- screen_width = 80
870
- screen_height = 24
871
- end
872
-
873
- # 中央位置を計算
874
- x = [(screen_width - content_width) / 2, 1].max
875
- y = [(screen_height - content_height) / 2, 1].max
876
-
877
- [x, y]
878
- end
879
-
880
- def clear_floating_window_area(x, y, width, height)
881
- # フローティングウィンドウの領域をクリア
882
- height.times do |row|
883
- print "\e[#{y + row};#{x}H#{' ' * width}"
884
- end
885
- end
886
682
 
887
683
  # ブックマーク機能
888
684
  def show_bookmark_menu
889
685
  current_path = @directory_listing&.current_path || Dir.pwd
890
-
891
- # メニューの準備
892
- title = 'Bookmark Menu'
893
- content_lines = [
894
- '',
895
- '[A]dd current directory to bookmarks',
896
- '[L]ist bookmarks',
897
- '[R]emove bookmark',
898
- '',
899
- 'Press 1-9 to go to bookmark directly',
900
- '',
901
- 'Press any other key to cancel'
902
- ]
903
-
904
- dialog_width = 45
905
- dialog_height = 4 + content_lines.length
906
- x, y = get_screen_center(dialog_width, dialog_height)
907
-
908
- # ダイアログの描画
909
- draw_floating_window(x, y, dialog_width, dialog_height, title, content_lines, {
910
- border_color: "\e[34m", # 青色
911
- title_color: "\e[1;34m", # 太字青色
912
- content_color: "\e[37m" # 白色
913
- })
686
+ result = @bookmark_manager.show_menu(current_path)
914
687
 
915
- # キー入力待機
916
- loop do
917
- input = STDIN.getch.downcase
918
-
919
- case input
920
- when 'a'
921
- clear_floating_window_area(x, y, dialog_width, dialog_height)
922
- @terminal_ui&.refresh_display
923
- add_bookmark_interactive(current_path)
924
- return true
925
- when 'l'
926
- clear_floating_window_area(x, y, dialog_width, dialog_height)
927
- @terminal_ui&.refresh_display
928
- list_bookmarks_interactive
929
- return true
930
- when 'r'
931
- clear_floating_window_area(x, y, dialog_width, dialog_height)
932
- @terminal_ui&.refresh_display
933
- remove_bookmark_interactive
934
- return true
935
- when '1', '2', '3', '4', '5', '6', '7', '8', '9'
936
- clear_floating_window_area(x, y, dialog_width, dialog_height)
937
- @terminal_ui&.refresh_display
938
- goto_bookmark(input.to_i)
939
- return true
940
- else
941
- # キャンセル
942
- clear_floating_window_area(x, y, dialog_width, dialog_height)
943
- @terminal_ui&.refresh_display
944
- return false
945
- end
946
- end
947
- end
948
-
949
- def add_bookmark_interactive(path)
950
- print ConfigLoader.message('bookmark.input_name') || "Enter bookmark name: "
951
- name = STDIN.gets.chomp
952
- return false if name.empty?
688
+ @terminal_ui&.refresh_display
953
689
 
954
- if @bookmark.add(path, name)
955
- puts "\n#{ConfigLoader.message('bookmark.added') || 'Bookmark added'}: #{name}"
690
+ case result[:action]
691
+ when :add
692
+ success = @bookmark_manager.add_interactive(result[:path])
693
+ wait_for_keypress
694
+ success
695
+ when :list
696
+ @bookmark_manager.list_interactive
697
+ wait_for_keypress
698
+ true
699
+ when :remove
700
+ @bookmark_manager.remove_interactive
701
+ wait_for_keypress
702
+ true
703
+ when :navigate
704
+ goto_bookmark(result[:number])
956
705
  else
957
- puts "\n#{ConfigLoader.message('bookmark.add_failed') || 'Failed to add bookmark'}"
706
+ false
958
707
  end
959
-
960
- print ConfigLoader.message('keybind.press_any_key') || 'Press any key to continue...'
961
- STDIN.getch
962
- true
963
708
  end
964
709
 
965
- def remove_bookmark_interactive
966
- bookmarks = @bookmark.list
967
-
968
- if bookmarks.empty?
969
- puts "\n#{ConfigLoader.message('bookmark.no_bookmarks') || 'No bookmarks found'}"
970
- print ConfigLoader.message('keybind.press_any_key') || 'Press any key to continue...'
971
- STDIN.getch
972
- return false
973
- end
710
+ def goto_bookmark(number)
711
+ bookmark = @bookmark_manager.find_by_number(number)
974
712
 
975
- puts "\nBookmarks:"
976
- bookmarks.each_with_index do |bookmark, index|
977
- puts " #{index + 1}. #{bookmark[:name]} (#{bookmark[:path]})"
978
- end
979
-
980
- print ConfigLoader.message('bookmark.input_number') || "Enter number to remove: "
981
- input = STDIN.gets.chomp
982
- number = input.to_i
983
-
984
- if number > 0 && number <= bookmarks.length
985
- bookmark_to_remove = bookmarks[number - 1]
986
- if @bookmark.remove(bookmark_to_remove[:name])
987
- puts "\n#{ConfigLoader.message('bookmark.removed') || 'Bookmark removed'}: #{bookmark_to_remove[:name]}"
988
- else
989
- puts "\n#{ConfigLoader.message('bookmark.remove_failed') || 'Failed to remove bookmark'}"
990
- end
713
+ return show_error_and_wait('bookmark.not_found', number) unless bookmark
714
+ return show_error_and_wait('bookmark.path_not_exist', bookmark[:path]) unless @bookmark_manager.path_exists?(bookmark)
715
+
716
+ # ディレクトリに移動
717
+ if navigate_to_directory(bookmark[:path])
718
+ puts "\n#{ConfigLoader.message('bookmark.navigated') || 'Navigated to bookmark'}: #{bookmark[:name]}"
719
+ sleep(0.5) # 短時間表示
720
+ true
991
721
  else
992
- puts "\n#{ConfigLoader.message('bookmark.invalid_number') || 'Invalid number'}"
722
+ show_error_and_wait('bookmark.navigate_failed', bookmark[:name])
993
723
  end
994
-
995
- print ConfigLoader.message('keybind.press_any_key') || 'Press any key to continue...'
996
- STDIN.getch
997
- true
998
724
  end
999
725
 
1000
- def list_bookmarks_interactive
1001
- bookmarks = @bookmark.list
1002
-
1003
- if bookmarks.empty?
1004
- puts "\n#{ConfigLoader.message('bookmark.no_bookmarks') || 'No bookmarks found'}"
1005
- print ConfigLoader.message('keybind.press_any_key') || 'Press any key to continue...'
1006
- STDIN.getch
1007
- return false
1008
- end
1009
-
1010
- puts "\nBookmarks:"
1011
- bookmarks.each_with_index do |bookmark, index|
1012
- puts " #{index + 1}. #{bookmark[:name]} (#{bookmark[:path]})"
1013
- end
1014
-
726
+ # ヘルパーメソッド
727
+ def wait_for_keypress
1015
728
  print ConfigLoader.message('keybind.press_any_key') || 'Press any key to continue...'
1016
729
  STDIN.getch
1017
- true
1018
730
  end
1019
731
 
1020
- def goto_bookmark(number)
1021
- bookmark = @bookmark.find_by_number(number)
1022
-
1023
- unless bookmark
1024
- puts "\n#{ConfigLoader.message('bookmark.not_found') || 'Bookmark not found'}: #{number}"
1025
- print ConfigLoader.message('keybind.press_any_key') || 'Press any key to continue...'
1026
- STDIN.getch
1027
- return false
1028
- end
1029
-
1030
- unless Dir.exist?(bookmark[:path])
1031
- puts "\n#{ConfigLoader.message('bookmark.path_not_exist') || 'Bookmark path does not exist'}: #{bookmark[:path]}"
1032
- print ConfigLoader.message('keybind.press_any_key') || 'Press any key to continue...'
1033
- STDIN.getch
1034
- return false
1035
- end
732
+ def show_error_and_wait(message_key, value)
733
+ puts "\n#{ConfigLoader.message(message_key) || message_key}: #{value}"
734
+ wait_for_keypress
735
+ false
736
+ end
1036
737
 
1037
- # ディレクトリに移動
1038
- result = @directory_listing.navigate_to_path(bookmark[:path])
738
+ def navigate_to_directory(path)
739
+ result = @directory_listing.navigate_to_path(path)
1039
740
  if result
1040
741
  @current_index = 0
1041
742
  clear_filter_mode
1042
- puts "\n#{ConfigLoader.message('bookmark.navigated') || 'Navigated to bookmark'}: #{bookmark[:name]}"
1043
- sleep(0.5) # 短時間表示
1044
- return true
743
+ true
1045
744
  else
1046
- puts "\n#{ConfigLoader.message('bookmark.navigate_failed') || 'Failed to navigate to bookmark'}: #{bookmark[:name]}"
1047
- print ConfigLoader.message('keybind.press_any_key') || 'Press any key to continue...'
1048
- STDIN.getch
1049
- return false
745
+ false
1050
746
  end
1051
747
  end
1052
748
 
1053
749
  # zoxide 機能
1054
750
  def show_zoxide_menu
1055
- history = get_zoxide_history
1056
-
1057
- if history.empty?
1058
- show_no_zoxide_history_message
1059
- return false
1060
- end
751
+ selected_path = @zoxide_integration.show_menu
1061
752
 
1062
- # zoxide履歴選択UI
1063
- selected_path = select_from_zoxide_history(history)
1064
-
1065
- if selected_path
1066
- navigate_to_zoxide_directory(selected_path)
753
+ if selected_path && Dir.exist?(selected_path)
754
+ if navigate_to_directory(selected_path)
755
+ @zoxide_integration.add_to_history(selected_path)
756
+ true
757
+ else
758
+ false
759
+ end
1067
760
  else
761
+ @terminal_ui&.refresh_display
1068
762
  false
1069
763
  end
1070
764
  end
1071
765
 
1072
766
  private
1073
-
1074
- def zoxide_available?
1075
- system('which zoxide > /dev/null 2>&1')
1076
- end
1077
-
1078
- def get_zoxide_history
1079
- return [] unless zoxide_available?
1080
-
1081
- begin
1082
- # zoxide query --list --score で履歴を取得(スコア順)
1083
- output = `zoxide query --list --score 2>/dev/null`.strip
1084
- return [] if output.empty?
1085
-
1086
- # 各行をパスとスコアに分けて配列に変換
1087
- lines = output.split("\n")
1088
- history = lines.map do |line|
1089
- # zoxide の出力は "スコア パス" の形式
1090
- if line.match(/^\s*(\d+(?:\.\d+)?)\s+(.+)$/)
1091
- score = $1.to_f
1092
- path = $2.strip
1093
- { path: path, score: score }
1094
- else
1095
- # スコアがない場合はパスのみ(後方互換性)
1096
- { path: line.strip, score: 0.0 }
1097
- end
1098
- end
1099
-
1100
- # 有効なディレクトリのみフィルタリング
1101
- history.select { |entry| Dir.exist?(entry[:path]) }
1102
- rescue StandardError
1103
- []
1104
- end
1105
- end
1106
-
1107
- def show_no_zoxide_history_message
1108
- title = 'Zoxide'
1109
- content_lines = [
1110
- '',
1111
- 'No zoxide history found.',
1112
- '',
1113
- 'Zoxide learns from your directory navigation.',
1114
- 'Use zoxide more to build up history.',
1115
- '',
1116
- 'Press any key to continue...'
1117
- ]
1118
-
1119
- dialog_width = 45
1120
- dialog_height = 4 + content_lines.length
1121
- x, y = get_screen_center(dialog_width, dialog_height)
1122
-
1123
- draw_floating_window(x, y, dialog_width, dialog_height, title, content_lines, {
1124
- border_color: "\e[33m", # 黄色
1125
- title_color: "\e[1;33m", # 太字黄色
1126
- content_color: "\e[37m" # 白色
1127
- })
1128
-
1129
- STDIN.getch
1130
- clear_floating_window_area(x, y, dialog_width, dialog_height)
1131
- @terminal_ui&.refresh_display
1132
- end
1133
-
1134
- def select_from_zoxide_history(history)
1135
- title = 'Zoxide History'
1136
-
1137
- # 履歴を表示用に整形(最大20件)
1138
- display_history = history.first(20)
1139
- content_lines = ['']
1140
-
1141
- display_history.each_with_index do |entry, index|
1142
- # パスの表示を短縮(ホームディレクトリを ~ に置換)
1143
- display_path = entry[:path].gsub(ENV['HOME'], '~')
1144
- line = " #{index + 1}. #{display_path}"
1145
- # 長すぎる場合は切り詰め
1146
- line = line[0...60] + '...' if line.length > 63
1147
- content_lines << line
1148
- end
1149
-
1150
- content_lines << ''
1151
- content_lines << 'Enter number (1-' + display_history.length.to_s + ') or ESC to cancel'
1152
-
1153
- dialog_width = 70
1154
- dialog_height = [4 + content_lines.length, 25].min
1155
- x, y = get_screen_center(dialog_width, dialog_height)
1156
-
1157
- draw_floating_window(x, y, dialog_width, dialog_height, title, content_lines, {
1158
- border_color: "\e[36m", # シアン色
1159
- title_color: "\e[1;36m", # 太字シアン色
1160
- content_color: "\e[37m" # 白色
1161
- })
1162
-
1163
- # 数字入力モード
1164
- input_buffer = ''
1165
-
1166
- loop do
1167
- char = STDIN.getch
1168
-
1169
- case char
1170
- when "\e", "\x03" # ESC, Ctrl+C
1171
- clear_floating_window_area(x, y, dialog_width, dialog_height)
1172
- @terminal_ui&.refresh_display
1173
- return nil
1174
- when "\r", "\n" # Enter
1175
- if !input_buffer.empty?
1176
- number = input_buffer.to_i
1177
- if number > 0 && number <= display_history.length
1178
- selected_entry = display_history[number - 1]
1179
- clear_floating_window_area(x, y, dialog_width, dialog_height)
1180
- @terminal_ui&.refresh_display
1181
- return selected_entry[:path]
1182
- end
1183
- end
1184
- # 無効な入力の場合は再度入力を求める
1185
- input_buffer = ''
1186
- when "\u007f", "\b" # Backspace
1187
- input_buffer = input_buffer[0...-1] unless input_buffer.empty?
1188
- when /[0-9]/
1189
- input_buffer += char
1190
- # 最大2桁まで
1191
- input_buffer = input_buffer[-2..-1] if input_buffer.length > 2
1192
-
1193
- # 入力された数字が範囲内の場合は即座に選択
1194
- number = input_buffer.to_i
1195
- if number > 0 && number <= display_history.length &&
1196
- (number >= 10 || input_buffer.length == 1)
1197
- selected_entry = display_history[number - 1]
1198
- clear_floating_window_area(x, y, dialog_width, dialog_height)
1199
- @terminal_ui&.refresh_display
1200
- return selected_entry[:path]
1201
- end
1202
- end
1203
- end
1204
- end
1205
-
1206
- def navigate_to_zoxide_directory(target_path)
1207
- return false unless Dir.exist?(target_path)
1208
-
1209
- # DirectoryListingのnavigate_to_pathメソッドを使用してディレクトリに移動
1210
- result = @directory_listing.navigate_to_path(target_path)
1211
- if result
1212
- @current_index = 0
1213
- clear_filter_mode
1214
-
1215
- # zoxide に移動を記録
1216
- begin
1217
- system("zoxide add #{Shellwords.escape(target_path)} > /dev/null 2>&1")
1218
- rescue StandardError
1219
- # zoxide add が失敗しても移動は成功として扱う
1220
- end
1221
-
1222
- true
1223
- else
1224
- false
1225
- end
1226
- end
1227
767
  end
1228
768
  end