beniya 0.3.0 → 0.5.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.
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'bookmark'
4
+
3
5
  module Beniya
4
6
  class KeybindHandler
5
- attr_reader :current_index
7
+ attr_reader :current_index, :filter_query
6
8
 
7
9
  def initialize
8
10
  @current_index = 0
@@ -10,11 +12,12 @@ module Beniya
10
12
  @terminal_ui = nil
11
13
  @file_opener = FileOpener.new
12
14
  @filter_mode = false
13
- @filter_query = ""
15
+ @filter_query = ''
14
16
  @filtered_entries = []
15
17
  @original_entries = []
16
18
  @selected_items = []
17
19
  @base_directory = nil
20
+ @bookmark = Bookmark.new
18
21
  end
19
22
 
20
23
  def set_directory_listing(directory_listing)
@@ -42,9 +45,7 @@ module Beniya
42
45
  return false unless @directory_listing
43
46
 
44
47
  # フィルターモード中は他のキーバインドを無効化
45
- if @filter_mode
46
- return handle_filter_input(key)
47
- end
48
+ return handle_filter_input(key) if @filter_mode
48
49
 
49
50
  case key
50
51
  when 'j'
@@ -53,7 +54,7 @@ module Beniya
53
54
  move_up
54
55
  when 'h'
55
56
  navigate_parent
56
- when 'l', "\r", "\n" # l, Enter
57
+ when 'l', "\r", "\n" # l, Enter
57
58
  navigate_enter
58
59
  when 'g'
59
60
  move_to_top
@@ -74,9 +75,9 @@ module Beniya
74
75
  # 新規フィルターモード開始
75
76
  start_filter_mode
76
77
  end
77
- when ' ' # Space - toggle selection
78
+ when ' ' # Space - toggle selection
78
79
  toggle_selection
79
- when "\e" # ESC
80
+ when "\e" # ESC
80
81
  if !@filter_query.empty?
81
82
  # フィルタが設定されている場合はクリア
82
83
  clear_filter_mode
@@ -102,8 +103,12 @@ module Beniya
102
103
  copy_selected_to_base
103
104
  when 'x' # x - delete selected files
104
105
  delete_selected_files
106
+ when 'b' # b - bookmark operations
107
+ show_bookmark_menu
108
+ when '1', '2', '3', '4', '5', '6', '7', '8', '9' # number keys - go to bookmark
109
+ goto_bookmark(key.to_i)
105
110
  else
106
- false # #{ConfigLoader.message('keybind.invalid_key')}
111
+ false # #{ConfigLoader.message('keybind.invalid_key')}
107
112
  end
108
113
  end
109
114
 
@@ -121,10 +126,6 @@ module Beniya
121
126
  @filter_mode || !@filter_query.empty?
122
127
  end
123
128
 
124
- def filter_query
125
- @filter_query
126
- end
127
-
128
129
  def get_active_entries
129
130
  if @filter_mode || !@filter_query.empty?
130
131
  @filtered_entries.empty? ? [] : @filtered_entries
@@ -161,7 +162,7 @@ module Beniya
161
162
  entry = current_entry
162
163
  return false unless entry
163
164
 
164
- if entry[:type] == "directory"
165
+ if entry[:type] == 'directory'
165
166
  result = @directory_listing.navigate_to(entry[:name])
166
167
  if result
167
168
  @current_index = 0 # select first entry in new directory
@@ -186,7 +187,7 @@ module Beniya
186
187
  def refresh
187
188
  # ウィンドウサイズを更新して画面を再描画
188
189
  @terminal_ui&.refresh_display
189
-
190
+
190
191
  @directory_listing.refresh
191
192
  if @filter_mode || !@filter_query.empty?
192
193
  # Re-apply filter with new directory contents
@@ -203,8 +204,8 @@ module Beniya
203
204
  def open_current_file
204
205
  entry = current_entry
205
206
  return false unless entry
206
-
207
- if entry[:type] == "file"
207
+
208
+ if entry[:type] == 'file'
208
209
  @file_opener.open_file(entry[:path])
209
210
  true
210
211
  else
@@ -219,78 +220,76 @@ module Beniya
219
220
  end
220
221
 
221
222
  def exit_request
222
- true # request exit
223
+ true # request exit
223
224
  end
224
225
 
225
226
  def fzf_search
226
227
  return false unless fzf_available?
227
-
228
+
228
229
  current_path = @directory_listing&.current_path || Dir.pwd
229
-
230
+
230
231
  # fzfでファイル検索を実行
231
232
  selected_file = `cd "#{current_path}" && find . -type f | fzf --preview 'cat {}'`.strip
232
-
233
+
233
234
  # ファイルが選択された場合、そのファイルを開く
234
235
  if !selected_file.empty? && File.exist?(File.join(current_path, selected_file))
235
236
  full_path = File.expand_path(selected_file, current_path)
236
237
  @file_opener.open_file(full_path)
237
238
  end
238
-
239
+
239
240
  true
240
241
  end
241
242
 
242
243
  def fzf_available?
243
- system("which fzf > /dev/null 2>&1")
244
+ system('which fzf > /dev/null 2>&1')
244
245
  end
245
246
 
246
247
  def rga_search
247
248
  return false unless rga_available?
248
-
249
+
249
250
  current_path = @directory_listing&.current_path || Dir.pwd
250
-
251
+
251
252
  # input search keyword
252
253
  print ConfigLoader.message('keybind.search_text')
253
254
  search_query = STDIN.gets.chomp
254
255
  return false if search_query.empty?
255
-
256
+
256
257
  # execute rga file content search
257
258
  search_results = `cd "#{current_path}" && rga --line-number --with-filename "#{search_query}" . 2>/dev/null`
258
-
259
+
259
260
  if search_results.empty?
260
261
  puts "\n#{ConfigLoader.message('keybind.no_matches')}"
261
262
  print ConfigLoader.message('keybind.press_any_key')
262
263
  STDIN.getch
263
264
  return true
264
265
  end
265
-
266
+
266
267
  # pass results to fzf for selection
267
- selected_result = IO.popen("fzf", "r+") do |fzf|
268
+ selected_result = IO.popen('fzf', 'r+') do |fzf|
268
269
  fzf.write(search_results)
269
270
  fzf.close_write
270
271
  fzf.read.strip
271
272
  end
272
-
273
+
273
274
  # extract file path and line number from selected result
274
275
  if !selected_result.empty? && selected_result.match(/^(.+?):(\d+):/)
275
- file_path = $1
276
- line_number = $2.to_i
276
+ file_path = ::Regexp.last_match(1)
277
+ line_number = ::Regexp.last_match(2).to_i
277
278
  full_path = File.expand_path(file_path, current_path)
278
-
279
- if File.exist?(full_path)
280
- @file_opener.open_file_with_line(full_path, line_number)
281
- end
279
+
280
+ @file_opener.open_file_with_line(full_path, line_number) if File.exist?(full_path)
282
281
  end
283
-
282
+
284
283
  true
285
284
  end
286
285
 
287
286
  def rga_available?
288
- system("which rga > /dev/null 2>&1")
287
+ system('which rga > /dev/null 2>&1')
289
288
  end
290
289
 
291
290
  def start_filter_mode
292
291
  @filter_mode = true
293
- @filter_query = ""
292
+ @filter_query = ''
294
293
  @original_entries = @directory_listing.list_entries.dup
295
294
  @filtered_entries = @original_entries.dup
296
295
  @current_index = 0
@@ -299,11 +298,11 @@ module Beniya
299
298
 
300
299
  def handle_filter_input(key)
301
300
  case key
302
- when "\e" # ESC - フィルタをクリアして通常モードに戻る
301
+ when "\e" # ESC - フィルタをクリアして通常モードに戻る
303
302
  clear_filter_mode
304
- when "\r", "\n" # Enter - フィルタを維持して通常モードに戻る
303
+ when "\r", "\n" # Enter - フィルタを維持して通常モードに戻る
305
304
  exit_filter_mode_keep_filter
306
- when "\u007f", "\b" # Backspace
305
+ when "\u007f", "\b" # Backspace
307
306
  if @filter_query.length > 0
308
307
  @filter_query = @filter_query[0...-1]
309
308
  apply_filter
@@ -312,10 +311,10 @@ module Beniya
312
311
  end
313
312
  else
314
313
  # printable characters (英数字、記号、日本語文字など)
315
- if key.length == 1 && key.ord >= 32 && key.ord < 127 # ASCII printable
314
+ if key.length == 1 && key.ord >= 32 && key.ord < 127 # ASCII printable
316
315
  @filter_query += key
317
316
  apply_filter
318
- elsif key.bytesize > 1 # Multi-byte characters (Japanese, etc.)
317
+ elsif key.bytesize > 1 # Multi-byte characters (Japanese, etc.)
319
318
  @filter_query += key
320
319
  apply_filter
321
320
  end
@@ -345,7 +344,7 @@ module Beniya
345
344
  def clear_filter_mode
346
345
  # フィルタをクリアして通常モードに戻る
347
346
  @filter_mode = false
348
- @filter_query = ""
347
+ @filter_query = ''
349
348
  @filtered_entries = []
350
349
  @original_entries = []
351
350
  @current_index = 0
@@ -358,12 +357,12 @@ module Beniya
358
357
 
359
358
  def create_file
360
359
  current_path = @directory_listing&.current_path || Dir.pwd
361
-
360
+
362
361
  # ファイル名の入力を求める
363
362
  print ConfigLoader.message('keybind.input_filename')
364
363
  filename = STDIN.gets.chomp
365
364
  return false if filename.empty?
366
-
365
+
367
366
  # 不正なファイル名のチェック
368
367
  if filename.include?('/') || filename.include?('\\')
369
368
  puts "\n#{ConfigLoader.message('keybind.invalid_filename')}"
@@ -371,9 +370,9 @@ module Beniya
371
370
  STDIN.getch
372
371
  return false
373
372
  end
374
-
373
+
375
374
  file_path = File.join(current_path, filename)
376
-
375
+
377
376
  # ファイルが既に存在する場合の確認
378
377
  if File.exist?(file_path)
379
378
  puts "\n#{ConfigLoader.message('keybind.file_exists')}"
@@ -381,24 +380,24 @@ module Beniya
381
380
  STDIN.getch
382
381
  return false
383
382
  end
384
-
383
+
385
384
  begin
386
385
  # ファイルを作成
387
386
  File.write(file_path, '')
388
-
387
+
389
388
  # ディレクトリ表示を更新
390
389
  @directory_listing.refresh
391
-
390
+
392
391
  # 作成したファイルを選択状態にする
393
392
  entries = @directory_listing.list_entries
394
393
  new_file_index = entries.find_index { |entry| entry[:name] == filename }
395
394
  @current_index = new_file_index if new_file_index
396
-
395
+
397
396
  puts "\n#{ConfigLoader.message('keybind.file_created')}: #{filename}"
398
397
  print ConfigLoader.message('keybind.press_any_key')
399
398
  STDIN.getch
400
399
  true
401
- rescue => e
400
+ rescue StandardError => e
402
401
  puts "\n#{ConfigLoader.message('keybind.creation_error')}: #{e.message}"
403
402
  print ConfigLoader.message('keybind.press_any_key')
404
403
  STDIN.getch
@@ -408,12 +407,12 @@ module Beniya
408
407
 
409
408
  def create_directory
410
409
  current_path = @directory_listing&.current_path || Dir.pwd
411
-
410
+
412
411
  # ディレクトリ名の入力を求める
413
412
  print ConfigLoader.message('keybind.input_dirname')
414
413
  dirname = STDIN.gets.chomp
415
414
  return false if dirname.empty?
416
-
415
+
417
416
  # 不正なディレクトリ名のチェック
418
417
  if dirname.include?('/') || dirname.include?('\\')
419
418
  puts "\n#{ConfigLoader.message('keybind.invalid_dirname')}"
@@ -421,9 +420,9 @@ module Beniya
421
420
  STDIN.getch
422
421
  return false
423
422
  end
424
-
423
+
425
424
  dir_path = File.join(current_path, dirname)
426
-
425
+
427
426
  # ディレクトリが既に存在する場合の確認
428
427
  if File.exist?(dir_path)
429
428
  puts "\n#{ConfigLoader.message('keybind.directory_exists')}"
@@ -431,24 +430,24 @@ module Beniya
431
430
  STDIN.getch
432
431
  return false
433
432
  end
434
-
433
+
435
434
  begin
436
435
  # ディレクトリを作成
437
436
  Dir.mkdir(dir_path)
438
-
437
+
439
438
  # ディレクトリ表示を更新
440
439
  @directory_listing.refresh
441
-
440
+
442
441
  # 作成したディレクトリを選択状態にする
443
442
  entries = @directory_listing.list_entries
444
443
  new_dir_index = entries.find_index { |entry| entry[:name] == dirname }
445
444
  @current_index = new_dir_index if new_dir_index
446
-
445
+
447
446
  puts "\n#{ConfigLoader.message('keybind.directory_created')}: #{dirname}"
448
447
  print ConfigLoader.message('keybind.press_any_key')
449
448
  STDIN.getch
450
449
  true
451
- rescue => e
450
+ rescue StandardError => e
452
451
  puts "\n#{ConfigLoader.message('keybind.creation_error')}: #{e.message}"
453
452
  print ConfigLoader.message('keybind.press_any_key')
454
453
  STDIN.getch
@@ -471,7 +470,7 @@ module Beniya
471
470
  def move_selected_to_base
472
471
  return false if @selected_items.empty? || @base_directory.nil?
473
472
 
474
- if show_confirmation_dialog("移動", @selected_items.length)
473
+ if show_confirmation_dialog('Move', @selected_items.length)
475
474
  perform_file_operation(:move, @selected_items, @base_directory)
476
475
  else
477
476
  false
@@ -481,7 +480,7 @@ module Beniya
481
480
  def copy_selected_to_base
482
481
  return false if @selected_items.empty? || @base_directory.nil?
483
482
 
484
- if show_confirmation_dialog("コピー", @selected_items.length)
483
+ if show_confirmation_dialog('Copy', @selected_items.length)
485
484
  perform_file_operation(:copy, @selected_items, @base_directory)
486
485
  else
487
486
  false
@@ -489,9 +488,9 @@ module Beniya
489
488
  end
490
489
 
491
490
  def show_confirmation_dialog(operation, count)
492
- print "\n#{count}個のアイテムを#{operation}しますか? (y/n): "
491
+ print "\n#{operation} #{count} item(s)? (y/n): "
493
492
  response = STDIN.gets.chomp.downcase
494
- response == 'y' || response == 'yes'
493
+ %w[y yes].include?(response)
495
494
  end
496
495
 
497
496
  def perform_file_operation(operation, items, destination)
@@ -506,13 +505,13 @@ module Beniya
506
505
  case operation
507
506
  when :move
508
507
  if File.exist?(dest_path)
509
- puts "\n#{item_name} は既に移動先に存在します。スキップします。"
508
+ puts "\n#{item_name} already exists in destination. Skipping."
510
509
  next
511
510
  end
512
511
  FileUtils.mv(source_path, dest_path)
513
512
  when :copy
514
513
  if File.exist?(dest_path)
515
- puts "\n#{item_name} は既に移動先に存在します。スキップします。"
514
+ puts "\n#{item_name} already exists in destination. Skipping."
516
515
  next
517
516
  end
518
517
  if File.directory?(source_path)
@@ -522,17 +521,17 @@ module Beniya
522
521
  end
523
522
  end
524
523
  success_count += 1
525
- rescue => e
526
- puts "\n#{item_name} の#{operation == :move ? '移動' : 'コピー'}に失敗: #{e.message}"
524
+ rescue StandardError => e
525
+ puts "\nFailed to #{operation == :move ? 'move' : 'copy'} #{item_name}: #{e.message}"
527
526
  end
528
527
  end
529
528
 
530
529
  # 操作完了後の処理
531
530
  @selected_items.clear
532
531
  @directory_listing.refresh if @directory_listing
533
-
534
- puts "\n#{success_count}個のアイテムを#{operation == :move ? '移動' : 'コピー'}しました。"
535
- print "何かキーを押してください..."
532
+
533
+ puts "\n#{operation == :move ? 'Moved' : 'Copied'} #{success_count} item(s)."
534
+ print 'Press any key to continue...'
536
535
  STDIN.getch
537
536
  true
538
537
  end
@@ -548,39 +547,503 @@ module Beniya
548
547
  end
549
548
 
550
549
  def show_delete_confirmation(count)
551
- print "\n#{count}個のアイテムを削除しますか? (y/n): "
552
- response = STDIN.gets.chomp.downcase
553
- response == 'y' || response == 'yes'
550
+ show_floating_delete_confirmation(count)
551
+ end
552
+
553
+ def show_floating_delete_confirmation(count)
554
+ # コンテンツの準備
555
+ title = 'Delete Confirmation'
556
+ content_lines = [
557
+ '',
558
+ "Delete #{count} item(s)?",
559
+ '',
560
+ ' [Y]es - Delete',
561
+ ' [N]o - Cancel',
562
+ ''
563
+ ]
564
+
565
+ # ダイアログのサイズ設定(コンテンツに合わせて調整)
566
+ dialog_width = 45
567
+ # タイトルあり: 上枠1 + タイトル1 + 区切り1 + コンテンツ6 + 下枠1 = 10
568
+ dialog_height = 4 + content_lines.length
569
+
570
+ # ダイアログの位置を中央に設定
571
+ x, y = get_screen_center(dialog_width, dialog_height)
572
+
573
+ # ダイアログの描画
574
+ draw_floating_window(x, y, dialog_width, dialog_height, title, content_lines, {
575
+ border_color: "\e[31m", # 赤色(警告)
576
+ title_color: "\e[1;31m", # 太字赤色
577
+ content_color: "\e[37m" # 白色
578
+ })
579
+
580
+ # フラッシュしてユーザーの注意を引く
581
+ print "\a" # ベル音
582
+
583
+ # キー入力待機
584
+ loop do
585
+ input = STDIN.getch.downcase
586
+
587
+ case input
588
+ when 'y'
589
+ # ダイアログをクリア
590
+ clear_floating_window_area(x, y, dialog_width, dialog_height)
591
+ @terminal_ui&.refresh_display # 画面を再描画
592
+ return true
593
+ when 'n', "\e", "\x03" # n, ESC, Ctrl+C
594
+ # ダイアログをクリア
595
+ clear_floating_window_area(x, y, dialog_width, dialog_height)
596
+ @terminal_ui&.refresh_display # 画面を再描画
597
+ return false
598
+ when 'q' # qキーでもキャンセル
599
+ clear_floating_window_area(x, y, dialog_width, dialog_height)
600
+ @terminal_ui&.refresh_display
601
+ return false
602
+ end
603
+ # 無効なキー入力の場合は再度ループ
604
+ end
554
605
  end
555
606
 
556
607
  def perform_delete_operation(items)
557
608
  success_count = 0
609
+ error_messages = []
558
610
  current_path = @directory_listing&.current_path || Dir.pwd
611
+ debug_log = []
559
612
 
560
613
  items.each do |item_name|
561
614
  item_path = File.join(current_path, item_name)
615
+ debug_log << "Processing: #{item_name}"
562
616
 
563
617
  begin
564
- if File.directory?(item_path)
618
+ # ファイル/ディレクトリの存在確認
619
+ unless File.exist?(item_path)
620
+ error_messages << "#{item_name}: File not found"
621
+ debug_log << ' Error: File not found'
622
+ next
623
+ end
624
+
625
+ debug_log << ' Existence check: OK'
626
+ is_directory = File.directory?(item_path)
627
+ debug_log << " Type: #{is_directory ? 'Directory' : 'File'}"
628
+
629
+ if is_directory
565
630
  FileUtils.rm_rf(item_path)
631
+ debug_log << ' FileUtils.rm_rf executed'
566
632
  else
567
633
  FileUtils.rm(item_path)
634
+ debug_log << ' FileUtils.rm executed'
568
635
  end
569
- success_count += 1
570
- puts "\n#{item_name} を削除しました。"
571
- rescue => e
572
- puts "\n#{item_name} の削除に失敗: #{e.message}"
636
+
637
+ # 削除が実際に成功したかを確認
638
+ sleep(0.01) # 10ms待機してファイルシステムの同期を待つ
639
+ still_exists = File.exist?(item_path)
640
+ debug_log << " Post-deletion check: #{still_exists}"
641
+
642
+ if still_exists
643
+ error_messages << "#{item_name}: Deletion failed"
644
+ debug_log << ' Result: Failed'
645
+ else
646
+ success_count += 1
647
+ debug_log << ' Result: Success'
648
+ end
649
+ rescue StandardError => e
650
+ error_messages << "#{item_name}: #{e.message}"
651
+ debug_log << " Exception: #{e.message}"
652
+ end
653
+ end
654
+
655
+ # デバッグログをファイルに出力(開発時のみ)
656
+ if ENV['BENIYA_DEBUG'] == '1'
657
+ debug_file = File.join(Dir.home, '.beniya_delete_debug.log')
658
+ File.open(debug_file, 'a') do |f|
659
+ f.puts "=== Delete Process Debug #{Time.now} ==="
660
+ f.puts "Target directory: #{current_path}"
661
+ f.puts "Target items: #{items.inspect}"
662
+ debug_log.each { |line| f.puts line }
663
+ f.puts "Final result: #{success_count} successful, #{items.length - success_count} failed"
664
+ f.puts "Error messages: #{error_messages.inspect}"
665
+ f.puts ''
573
666
  end
574
667
  end
575
668
 
669
+
670
+ # デバッグ用:削除結果の値をログファイルに出力
671
+ result_debug_file = File.join(Dir.home, '.beniya_result_debug.log')
672
+ File.open(result_debug_file, 'a') do |f|
673
+ f.puts "=== Delete Result Debug #{Time.now} ==="
674
+ f.puts "success_count: #{success_count}"
675
+ f.puts "total_count: #{items.length}"
676
+ f.puts "error_messages.length: #{error_messages.length}"
677
+ f.puts "has_errors: #{!error_messages.empty?}"
678
+ f.puts "condition check: success_count == total_count && !has_errors = #{success_count == items.length && error_messages.empty?}"
679
+ f.puts ""
680
+ end
681
+
682
+ # 削除結果をフローティングウィンドウで表示
683
+ show_deletion_result(success_count, items.length, error_messages)
684
+
576
685
  # 削除完了後の処理
577
686
  @selected_items.clear
578
687
  @directory_listing.refresh if @directory_listing
688
+
689
+ true
690
+ end
691
+
692
+ def show_deletion_result(success_count, total_count, error_messages = [])
693
+ # 詳細デバッグログを出力
694
+ detailed_debug_file = File.join(Dir.home, '.beniya_detailed_debug.log')
695
+ File.open(detailed_debug_file, 'a') do |f|
696
+ f.puts "=== show_deletion_result called #{Time.now} ==="
697
+ f.puts "Arguments: success_count=#{success_count}, total_count=#{total_count}"
698
+ f.puts "error_messages: #{error_messages.inspect}"
699
+ f.puts "error_messages.empty?: #{error_messages.empty?}"
700
+ f.puts ""
701
+ end
702
+
703
+ # エラーメッセージがある場合はダイアログサイズを拡大
704
+ has_errors = !error_messages.empty?
705
+ dialog_width = has_errors ? 50 : 35
706
+ dialog_height = has_errors ? [8 + error_messages.length, 15].min : 6
707
+
708
+ # ダイアログの位置を中央に設定
709
+ x, y = get_screen_center(dialog_width, dialog_height)
710
+
711
+ # 成功・失敗に応じた色設定
712
+ # デバッグ: success_count == total_count かつ has_errors が false の場合のみ成功扱い
713
+ if success_count == total_count && !has_errors
714
+ border_color = "\e[32m" # 緑色(成功)
715
+ title_color = "\e[1;32m" # 太字緑色
716
+ title = 'Delete Complete'
717
+ message = "Deleted #{success_count} item(s)"
718
+ else
719
+ border_color = "\e[33m" # 黄色(警告)
720
+ title_color = "\e[1;33m" # 太字黄色
721
+ title = 'Delete Result'
722
+ if success_count == total_count && has_errors
723
+ # 全て削除成功したがエラーメッセージがある場合(本来ここに入らないはず)
724
+ message = "#{success_count} deleted (with error info)"
725
+ else
726
+ failed_count = total_count - success_count
727
+ message = "#{success_count} deleted, #{failed_count} failed"
728
+ end
729
+ end
730
+
731
+ # コンテンツの準備
732
+ content_lines = ['', message]
733
+
734
+ # デバッグ情報を追加(開発中のみ)
735
+ content_lines << ""
736
+ content_lines << "DEBUG: success=#{success_count}, total=#{total_count}, errors=#{error_messages.length}"
737
+
738
+ # エラーメッセージがある場合は追加
739
+ if has_errors
740
+ content_lines << ''
741
+ content_lines << 'Error details:'
742
+ error_messages.each { |error| content_lines << " #{error}" }
743
+ end
744
+
745
+ content_lines << ''
746
+ content_lines << 'Press any key to continue...'
747
+
748
+ # ダイアログの描画
749
+ draw_floating_window(x, y, dialog_width, dialog_height, title, content_lines, {
750
+ border_color: border_color,
751
+ title_color: title_color,
752
+ content_color: "\e[37m"
753
+ })
754
+
755
+ # キー入力待機
756
+ STDIN.getch
757
+
758
+ # ダイアログをクリア
759
+ clear_floating_window_area(x, y, dialog_width, dialog_height)
760
+ @terminal_ui&.refresh_display
761
+ end
762
+
763
+ # フローティングウィンドウの基盤メソッド
764
+ def draw_floating_window(x, y, width, height, title, content_lines, options = {})
765
+ # デフォルトオプション
766
+ border_color = options[:border_color] || "\e[37m" # 白色
767
+ title_color = options[:title_color] || "\e[1;33m" # 黄色(太字)
768
+ content_color = options[:content_color] || "\e[37m" # 白色
769
+ reset_color = "\e[0m"
770
+
771
+ # ウィンドウの描画
772
+ # 上辺
773
+ print "\e[#{y};#{x}H#{border_color}┌#{'─' * (width - 2)}┐#{reset_color}"
774
+
775
+ # タイトル行
776
+ if title
777
+ title_width = display_width(title)
778
+ title_padding = (width - 2 - title_width) / 2
779
+ padded_title = ' ' * title_padding + title
780
+ title_line = pad_string_to_width(padded_title, width - 2)
781
+ print "\e[#{y + 1};#{x}H#{border_color}│#{title_color}#{title_line}#{border_color}│#{reset_color}"
782
+
783
+ # タイトル区切り線
784
+ print "\e[#{y + 2};#{x}H#{border_color}├#{'─' * (width - 2)}┤#{reset_color}"
785
+ content_start_y = y + 3
786
+ else
787
+ content_start_y = y + 1
788
+ end
789
+
790
+ # コンテンツ行
791
+ content_height = title ? height - 4 : height - 2
792
+ content_lines.each_with_index do |line, index|
793
+ break if index >= content_height
794
+
795
+ line_y = content_start_y + index
796
+ line_content = pad_string_to_width(line, width - 2) # 正確な幅でパディング
797
+ print "\e[#{line_y};#{x}H#{border_color}│#{content_color}#{line_content}#{border_color}│#{reset_color}"
798
+ end
799
+
800
+ # 空行を埋める
801
+ remaining_lines = content_height - content_lines.length
802
+ remaining_lines.times do |i|
803
+ line_y = content_start_y + content_lines.length + i
804
+ empty_line = ' ' * (width - 2)
805
+ print "\e[#{line_y};#{x}H#{border_color}│#{empty_line}│#{reset_color}"
806
+ end
807
+
808
+ # 下辺
809
+ bottom_y = y + height - 1
810
+ print "\e[#{bottom_y};#{x}H#{border_color}└#{'─' * (width - 2)}┘#{reset_color}"
811
+ end
812
+
813
+ def display_width(str)
814
+ # 日本語文字の幅を考慮した文字列幅の計算
815
+ # Unicode East Asian Width プロパティを考慮
816
+ str.each_char.map do |char|
817
+ case char
818
+ when /[\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF\uFF00-\uFFEF]/
819
+ # 日本語の文字(ひらがな、カタカナ、漢字、全角記号)
820
+ 2
821
+ when /[\u0020-\u007E]/
822
+ # ASCII文字
823
+ 1
824
+ else
825
+ # その他の文字はバイト数で判断
826
+ char.bytesize > 1 ? 2 : 1
827
+ end
828
+ end.sum
829
+ end
830
+
831
+ def pad_string_to_width(str, target_width)
832
+ # 文字列を指定した表示幅になるようにパディング
833
+ current_width = display_width(str)
834
+ if current_width >= target_width
835
+ # 文字列が長すぎる場合は切り詰め
836
+ truncate_to_width(str, target_width)
837
+ else
838
+ # 不足分をスペースで埋める
839
+ str + ' ' * (target_width - current_width)
840
+ end
841
+ end
842
+
843
+ def truncate_to_width(str, max_width)
844
+ # 指定した表示幅に収まるように文字列を切り詰め
845
+ result = ''
846
+ current_width = 0
847
+
848
+ str.each_char do |char|
849
+ char_width = display_width(char)
850
+ break if current_width + char_width > max_width
851
+
852
+ result += char
853
+ current_width += char_width
854
+ end
855
+
856
+ result
857
+ end
858
+
859
+ def get_screen_center(content_width, content_height)
860
+ # ターミナルのサイズを取得
861
+ console = IO.console
862
+ if console
863
+ screen_width, screen_height = console.winsize.reverse
864
+ else
865
+ screen_width = 80
866
+ screen_height = 24
867
+ end
868
+
869
+ # 中央位置を計算
870
+ x = [(screen_width - content_width) / 2, 1].max
871
+ y = [(screen_height - content_height) / 2, 1].max
872
+
873
+ [x, y]
874
+ end
875
+
876
+ def clear_floating_window_area(x, y, width, height)
877
+ # フローティングウィンドウの領域をクリア
878
+ height.times do |row|
879
+ print "\e[#{y + row};#{x}H#{' ' * width}"
880
+ end
881
+ end
882
+
883
+ # ブックマーク機能
884
+ def show_bookmark_menu
885
+ current_path = @directory_listing&.current_path || Dir.pwd
579
886
 
580
- puts "\n#{success_count}個のアイテムを削除しました。"
581
- print "何かキーを押してください..."
887
+ # メニューの準備
888
+ title = 'Bookmark Menu'
889
+ content_lines = [
890
+ '',
891
+ '[A]dd current directory to bookmarks',
892
+ '[L]ist bookmarks',
893
+ '[R]emove bookmark',
894
+ '',
895
+ 'Press 1-9 to go to bookmark directly',
896
+ '',
897
+ 'Press any other key to cancel'
898
+ ]
899
+
900
+ dialog_width = 45
901
+ dialog_height = 4 + content_lines.length
902
+ x, y = get_screen_center(dialog_width, dialog_height)
903
+
904
+ # ダイアログの描画
905
+ draw_floating_window(x, y, dialog_width, dialog_height, title, content_lines, {
906
+ border_color: "\e[34m", # 青色
907
+ title_color: "\e[1;34m", # 太字青色
908
+ content_color: "\e[37m" # 白色
909
+ })
910
+
911
+ # キー入力待機
912
+ loop do
913
+ input = STDIN.getch.downcase
914
+
915
+ case input
916
+ when 'a'
917
+ clear_floating_window_area(x, y, dialog_width, dialog_height)
918
+ @terminal_ui&.refresh_display
919
+ add_bookmark_interactive(current_path)
920
+ return true
921
+ when 'l'
922
+ clear_floating_window_area(x, y, dialog_width, dialog_height)
923
+ @terminal_ui&.refresh_display
924
+ list_bookmarks_interactive
925
+ return true
926
+ when 'r'
927
+ clear_floating_window_area(x, y, dialog_width, dialog_height)
928
+ @terminal_ui&.refresh_display
929
+ remove_bookmark_interactive
930
+ return true
931
+ when '1', '2', '3', '4', '5', '6', '7', '8', '9'
932
+ clear_floating_window_area(x, y, dialog_width, dialog_height)
933
+ @terminal_ui&.refresh_display
934
+ goto_bookmark(input.to_i)
935
+ return true
936
+ else
937
+ # キャンセル
938
+ clear_floating_window_area(x, y, dialog_width, dialog_height)
939
+ @terminal_ui&.refresh_display
940
+ return false
941
+ end
942
+ end
943
+ end
944
+
945
+ def add_bookmark_interactive(path)
946
+ print ConfigLoader.message('bookmark.input_name') || "Enter bookmark name: "
947
+ name = STDIN.gets.chomp
948
+ return false if name.empty?
949
+
950
+ if @bookmark.add(path, name)
951
+ puts "\n#{ConfigLoader.message('bookmark.added') || 'Bookmark added'}: #{name}"
952
+ else
953
+ puts "\n#{ConfigLoader.message('bookmark.add_failed') || 'Failed to add bookmark'}"
954
+ end
955
+
956
+ print ConfigLoader.message('keybind.press_any_key') || 'Press any key to continue...'
957
+ STDIN.getch
958
+ true
959
+ end
960
+
961
+ def remove_bookmark_interactive
962
+ bookmarks = @bookmark.list
963
+
964
+ if bookmarks.empty?
965
+ puts "\n#{ConfigLoader.message('bookmark.no_bookmarks') || 'No bookmarks found'}"
966
+ print ConfigLoader.message('keybind.press_any_key') || 'Press any key to continue...'
967
+ STDIN.getch
968
+ return false
969
+ end
970
+
971
+ puts "\nBookmarks:"
972
+ bookmarks.each_with_index do |bookmark, index|
973
+ puts " #{index + 1}. #{bookmark[:name]} (#{bookmark[:path]})"
974
+ end
975
+
976
+ print ConfigLoader.message('bookmark.input_number') || "Enter number to remove: "
977
+ input = STDIN.gets.chomp
978
+ number = input.to_i
979
+
980
+ if number > 0 && number <= bookmarks.length
981
+ bookmark_to_remove = bookmarks[number - 1]
982
+ if @bookmark.remove(bookmark_to_remove[:name])
983
+ puts "\n#{ConfigLoader.message('bookmark.removed') || 'Bookmark removed'}: #{bookmark_to_remove[:name]}"
984
+ else
985
+ puts "\n#{ConfigLoader.message('bookmark.remove_failed') || 'Failed to remove bookmark'}"
986
+ end
987
+ else
988
+ puts "\n#{ConfigLoader.message('bookmark.invalid_number') || 'Invalid number'}"
989
+ end
990
+
991
+ print ConfigLoader.message('keybind.press_any_key') || 'Press any key to continue...'
582
992
  STDIN.getch
583
993
  true
584
994
  end
995
+
996
+ def list_bookmarks_interactive
997
+ bookmarks = @bookmark.list
998
+
999
+ if bookmarks.empty?
1000
+ puts "\n#{ConfigLoader.message('bookmark.no_bookmarks') || 'No bookmarks found'}"
1001
+ print ConfigLoader.message('keybind.press_any_key') || 'Press any key to continue...'
1002
+ STDIN.getch
1003
+ return false
1004
+ end
1005
+
1006
+ puts "\nBookmarks:"
1007
+ bookmarks.each_with_index do |bookmark, index|
1008
+ puts " #{index + 1}. #{bookmark[:name]} (#{bookmark[:path]})"
1009
+ end
1010
+
1011
+ print ConfigLoader.message('keybind.press_any_key') || 'Press any key to continue...'
1012
+ STDIN.getch
1013
+ true
1014
+ end
1015
+
1016
+ def goto_bookmark(number)
1017
+ bookmark = @bookmark.find_by_number(number)
1018
+
1019
+ unless bookmark
1020
+ puts "\n#{ConfigLoader.message('bookmark.not_found') || 'Bookmark not found'}: #{number}"
1021
+ print ConfigLoader.message('keybind.press_any_key') || 'Press any key to continue...'
1022
+ STDIN.getch
1023
+ return false
1024
+ end
1025
+
1026
+ unless Dir.exist?(bookmark[:path])
1027
+ puts "\n#{ConfigLoader.message('bookmark.path_not_exist') || 'Bookmark path does not exist'}: #{bookmark[:path]}"
1028
+ print ConfigLoader.message('keybind.press_any_key') || 'Press any key to continue...'
1029
+ STDIN.getch
1030
+ return false
1031
+ end
1032
+
1033
+ # ディレクトリに移動
1034
+ result = @directory_listing.navigate_to_path(bookmark[:path])
1035
+ if result
1036
+ @current_index = 0
1037
+ clear_filter_mode
1038
+ puts "\n#{ConfigLoader.message('bookmark.navigated') || 'Navigated to bookmark'}: #{bookmark[:name]}"
1039
+ sleep(0.5) # 短時間表示
1040
+ return true
1041
+ else
1042
+ puts "\n#{ConfigLoader.message('bookmark.navigate_failed') || 'Failed to navigate to bookmark'}: #{bookmark[:name]}"
1043
+ print ConfigLoader.message('keybind.press_any_key') || 'Press any key to continue...'
1044
+ STDIN.getch
1045
+ return false
1046
+ end
1047
+ end
585
1048
  end
586
1049
  end