rufio 1.1.0 → 1.3.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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/README.md +74 -3
  4. data/README_ja.md +71 -2
  5. data/Rakefile +14 -1
  6. data/bin/rufio +31 -2
  7. data/docs/fast_zip_design_ja.md +167 -0
  8. data/info/help.md +2 -0
  9. data/info/keybindings.md +3 -1
  10. data/info/welcome.md +1 -1
  11. data/lib/rufio/application.rb +12 -1
  12. data/lib/rufio/archive_controller.rb +211 -0
  13. data/lib/rufio/background_command_executor.rb +9 -0
  14. data/lib/rufio/background_status.rb +49 -0
  15. data/lib/rufio/bookmark_controller.rb +38 -121
  16. data/lib/rufio/bookmark_manager.rb +77 -331
  17. data/lib/rufio/command_completion.rb +6 -1
  18. data/lib/rufio/command_history.rb +5 -2
  19. data/lib/rufio/command_mode.rb +40 -134
  20. data/lib/rufio/command_mode_ui.rb +4 -42
  21. data/lib/rufio/config.rb +88 -136
  22. data/lib/rufio/config_loader.rb +3 -11
  23. data/lib/rufio/dialog_renderer.rb +64 -152
  24. data/lib/rufio/dialog_support.rb +69 -0
  25. data/lib/rufio/document_text_extractor.rb +167 -0
  26. data/lib/rufio/file_operation_controller.rb +49 -222
  27. data/lib/rufio/file_preview.rb +46 -2
  28. data/lib/rufio/global_search.rb +121 -9
  29. data/lib/rufio/help_menu.rb +8 -2
  30. data/lib/rufio/interpreter_resolver.rb +6 -11
  31. data/lib/rufio/job_manager.rb +2 -5
  32. data/lib/rufio/key_input.rb +77 -0
  33. data/lib/rufio/keybind_handler.rb +81 -115
  34. data/lib/rufio/local_script_scanner.rb +6 -18
  35. data/lib/rufio/native/aarch64-linux-gnu/librufio_zig.so +0 -0
  36. data/lib/rufio/native/aarch64-linux-musl/librufio_zig.so +0 -0
  37. data/lib/rufio/native/aarch64-macos/librufio_zig.dylib +0 -0
  38. data/lib/rufio/native/aarch64-windows/rufio_zig.dll +0 -0
  39. data/lib/rufio/native/x86_64-linux-gnu/librufio_zig.so +0 -0
  40. data/lib/rufio/native/x86_64-linux-musl/librufio_zig.so +0 -0
  41. data/lib/rufio/native/x86_64-macos/librufio_zig.dylib +0 -0
  42. data/lib/rufio/native/x86_64-windows/rufio_zig.dll +0 -0
  43. data/lib/rufio/native_content_search.rb +153 -0
  44. data/lib/rufio/native_scanner.rb +13 -68
  45. data/lib/rufio/native_scanner_zig.rb +106 -56
  46. data/lib/rufio/native_zip.rb +121 -0
  47. data/lib/rufio/navigation_controller.rb +18 -20
  48. data/lib/rufio/screen.rb +2 -12
  49. data/lib/rufio/script_executor.rb +6 -73
  50. data/lib/rufio/script_file.rb +44 -0
  51. data/lib/rufio/script_path_manager.rb +9 -43
  52. data/lib/rufio/script_runner.rb +13 -50
  53. data/lib/rufio/search_controller.rb +1 -1
  54. data/lib/rufio/shell_command_completion.rb +1 -5
  55. data/lib/rufio/shell_init.rb +90 -0
  56. data/lib/rufio/task_status.rb +5 -1
  57. data/lib/rufio/terminal_ui.rb +55 -294
  58. data/lib/rufio/text_utils.rb +26 -3
  59. data/lib/rufio/ui_renderer.rb +63 -40
  60. data/lib/rufio/version.rb +1 -1
  61. data/lib/rufio/zip_archive.rb +314 -0
  62. data/lib/rufio/zip_extractor.rb +346 -0
  63. data/lib/rufio/zip_writer.rb +268 -0
  64. data/lib/rufio/zoxide_integration.rb +5 -43
  65. data/lib/rufio.rb +5 -0
  66. data/lib_zig/rufio_native/Makefile +54 -17
  67. data/lib_zig/rufio_native/bench.sh +36 -0
  68. data/lib_zig/rufio_native/build.zig +32 -35
  69. data/lib_zig/rufio_native/src/cli.zig +99 -0
  70. data/lib_zig/rufio_native/src/main.zig +187 -83
  71. data/lib_zig/rufio_native/src/os.zig +889 -0
  72. data/lib_zig/rufio_native/src/search.zig +919 -0
  73. data/lib_zig/rufio_native/src/search_test.zig +88 -0
  74. data/lib_zig/rufio_native/src/zip.zig +482 -0
  75. metadata +125 -3
  76. data/lib/rufio/native/rufio_zig.bundle +0 -0
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'dialog_support'
4
+
3
5
  require_relative 'file_operations'
4
6
  require_relative 'dialog_renderer'
5
7
  require_relative 'logger'
@@ -8,6 +10,8 @@ module Rufio
8
10
  # ファイル操作専用コントローラ
9
11
  # KeybindHandler からファイル作成・削除・リネーム・移動・コピー系メソッドを分離し、単一責任原則に準拠
10
12
  class FileOperationController
13
+ include DialogSupport
14
+
11
15
  # Dialog size constants
12
16
  CONFIRMATION_DIALOG_WIDTH = 45
13
17
  DIALOG_BORDER_HEIGHT = 4
@@ -35,51 +39,15 @@ module Rufio
35
39
  # ============================
36
40
 
37
41
  def create_file
38
- current_path = @directory_listing&.current_path || Dir.pwd
39
-
40
- filename = @dialog_renderer.show_input_dialog("Create File", "Enter file name:", {
41
- border_color: "\e[32m",
42
- title_color: "\e[1;32m",
43
- content_color: "\e[37m"
44
- })
45
-
46
- return false if filename.nil? || filename.empty?
47
-
48
- result = @file_operations.create_file(current_path, filename)
49
-
50
- if result.success
51
- @directory_listing.refresh
52
- entries = @directory_listing.list_entries
53
- new_file_index = entries.find_index { |entry| entry[:name] == filename }
54
- @nav_controller.select_index(new_file_index) if new_file_index
42
+ prompt_and_select_entry("Create File", "Enter file name:", "\e[32m", "\e[1;32m") do |path, name|
43
+ @file_operations.create_file(path, name)
55
44
  end
56
-
57
- @terminal_ui&.refresh_display
58
- result.success
59
45
  end
60
46
 
61
47
  def create_directory
62
- current_path = @directory_listing&.current_path || Dir.pwd
63
-
64
- dirname = @dialog_renderer.show_input_dialog("Create Directory", "Enter directory name:", {
65
- border_color: "\e[34m",
66
- title_color: "\e[1;34m",
67
- content_color: "\e[37m"
68
- })
69
-
70
- return false if dirname.nil? || dirname.empty?
71
-
72
- result = @file_operations.create_directory(current_path, dirname)
73
-
74
- if result.success
75
- @directory_listing.refresh
76
- entries = @directory_listing.list_entries
77
- new_dir_index = entries.find_index { |entry| entry[:name] == dirname }
78
- @nav_controller.select_index(new_dir_index) if new_dir_index
48
+ prompt_and_select_entry("Create Directory", "Enter directory name:", "\e[34m", "\e[1;34m") do |path, name|
49
+ @file_operations.create_directory(path, name)
79
50
  end
80
-
81
- @terminal_ui&.refresh_display
82
- result.success
83
51
  end
84
52
 
85
53
  # ============================
@@ -91,27 +59,9 @@ module Rufio
91
59
  return false unless entry
92
60
 
93
61
  current_name = entry[:name]
94
- current_path = @directory_listing&.current_path || Dir.pwd
95
-
96
- new_name = @dialog_renderer.show_input_dialog("Rename: #{current_name}", "Enter new name:", {
97
- border_color: "\e[33m",
98
- title_color: "\e[1;33m",
99
- content_color: "\e[37m"
100
- })
101
-
102
- return false if new_name.nil? || new_name.empty?
103
-
104
- result = @file_operations.rename(current_path, current_name, new_name)
105
-
106
- if result.success
107
- @directory_listing.refresh
108
- entries = @directory_listing.list_entries
109
- new_index = entries.find_index { |e| e[:name] == new_name }
110
- @nav_controller.select_index(new_index) if new_index
62
+ prompt_and_select_entry("Rename: #{current_name}", "Enter new name:", "\e[33m", "\e[1;33m") do |path, new_name|
63
+ @file_operations.rename(path, current_name, new_name)
111
64
  end
112
-
113
- @terminal_ui&.refresh_display
114
- result.success
115
65
  end
116
66
 
117
67
  # ============================
@@ -154,31 +104,13 @@ module Rufio
154
104
  ''
155
105
  ]
156
106
 
157
- title = 'Confirm Delete'
158
- width = [50, current_name.length + 10].max
159
- height = content_lines.length + 4
160
-
161
- confirmed = false
162
- show_overlay_dialog(title, content_lines, {
163
- width: width,
164
- height: height,
107
+ confirmed = confirm_dialog('Confirm Delete', content_lines, {
108
+ width: [50, current_name.length + 10].max,
109
+ height: content_lines.length + 4,
165
110
  border_color: "\e[31m",
166
111
  title_color: "\e[1;31m",
167
112
  content_color: "\e[37m"
168
- }) do
169
- loop do
170
- input = STDIN.getch.downcase
171
- case input
172
- when 'y'
173
- confirmed = true
174
- break
175
- when 'n', "\e"
176
- confirmed = false
177
- break
178
- end
179
- end
180
- nil
181
- end
113
+ })
182
114
 
183
115
  return false unless confirmed
184
116
 
@@ -260,69 +192,39 @@ module Rufio
260
192
  ''
261
193
  ]
262
194
 
263
- confirmed = false
264
- show_overlay_dialog('Exit Confirmation', content_lines, {
195
+ confirm_dialog('Exit Confirmation', content_lines, {
265
196
  width: CONFIRMATION_DIALOG_WIDTH,
266
197
  height: DIALOG_BORDER_HEIGHT + content_lines.length,
267
198
  border_color: "\e[33m",
268
199
  title_color: "\e[1;33m",
269
200
  content_color: "\e[37m"
270
- }) do
271
- loop do
272
- input = STDIN.getch.downcase
273
- case input
274
- when 'y'
275
- confirmed = true
276
- break
277
- when 'n', "\e", "\x03"
278
- confirmed = false
279
- break
280
- end
281
- end
282
- nil
283
- end
284
-
285
- confirmed
201
+ }, cancel_on_quit: false)
286
202
  end
287
203
 
288
204
  private
289
205
 
290
- def show_overlay_dialog(title, content_lines, options = {}, &block)
291
- use_overlay = @terminal_ui &&
292
- @terminal_ui.respond_to?(:screen) &&
293
- @terminal_ui.respond_to?(:renderer) &&
294
- @terminal_ui.screen &&
295
- @terminal_ui.renderer
296
-
297
- if use_overlay
298
- @terminal_ui.show_overlay_dialog(title, content_lines, options, &block)
299
- else
300
- width = options[:width]
301
- height = options[:height]
302
-
303
- unless width && height
304
- width, height = @dialog_renderer.calculate_dimensions(content_lines, {
305
- title: title,
306
- min_width: options[:min_width] || 40,
307
- max_width: options[:max_width] || 80
308
- })
309
- end
310
-
311
- x, y = @dialog_renderer.calculate_center(width, height)
206
+ def prompt_and_select_entry(title, prompt, border_color, title_color)
207
+ current_path = @directory_listing&.current_path || Dir.pwd
312
208
 
313
- @dialog_renderer.draw_floating_window(x, y, width, height, title, content_lines, {
314
- border_color: options[:border_color] || "\e[37m",
315
- title_color: options[:title_color] || "\e[1;33m",
316
- content_color: options[:content_color] || "\e[37m"
317
- })
209
+ name = @dialog_renderer.show_input_dialog(title, prompt, {
210
+ border_color: border_color,
211
+ title_color: title_color,
212
+ content_color: "\e[37m"
213
+ })
318
214
 
319
- key = block_given? ? yield : STDIN.getch
215
+ return false if name.nil? || name.empty?
320
216
 
321
- @dialog_renderer.clear_area(x, y, width, height)
322
- @terminal_ui&.refresh_display
217
+ result = yield(current_path, name)
323
218
 
324
- key
219
+ if result.success
220
+ @directory_listing.refresh
221
+ entries = @directory_listing.list_entries
222
+ new_index = entries.find_index { |e| e[:name] == name }
223
+ @nav_controller.select_index(new_index) if new_index
325
224
  end
225
+
226
+ @terminal_ui&.refresh_display
227
+ result.success
326
228
  end
327
229
 
328
230
  def show_delete_confirmation(count, source_path = nil)
@@ -342,111 +244,37 @@ module Rufio
342
244
 
343
245
  print "\a"
344
246
 
345
- confirmed = false
346
- show_overlay_dialog(title, content_lines, {
247
+ confirm_dialog(title, content_lines, {
347
248
  width: dialog_width,
348
249
  height: dialog_height,
349
250
  border_color: "\e[31m",
350
251
  title_color: "\e[1;31m",
351
252
  content_color: "\e[37m"
352
- }) do
353
- loop do
354
- input = STDIN.getch.downcase
355
- case input
356
- when 'y'
357
- confirmed = true
358
- break
359
- when 'n', "\e", "\x03", 'q'
360
- confirmed = false
361
- break
362
- end
363
- end
364
- nil
365
- end
366
-
367
- confirmed
253
+ })
368
254
  end
369
255
 
370
256
  def show_move_confirmation(count, source_path, dest_path)
371
- source_display = shorten_path(source_path, 35)
372
- dest_display = shorten_path(dest_path, 35)
373
-
374
- content_lines = [
375
- '',
376
- "Move #{count} item(s)?",
377
- '',
378
- "From: #{source_display}",
379
- "To: #{dest_display}",
380
- '',
381
- ' [Y]es - Move',
382
- ' [N]o - Cancel',
383
- ''
384
- ]
385
-
386
- confirmed = false
387
- show_overlay_dialog('Move Confirmation', content_lines, {
388
- width: CONFIRMATION_DIALOG_WIDTH,
389
- height: DIALOG_BORDER_HEIGHT + content_lines.length,
390
- border_color: "\e[34m",
391
- title_color: "\e[1;34m",
392
- content_color: "\e[37m"
393
- }) do
394
- loop do
395
- input = STDIN.getch.downcase
396
- case input
397
- when 'y'
398
- confirmed = true
399
- break
400
- when 'n', "\e", "\x03", 'q'
401
- confirmed = false
402
- break
403
- end
404
- end
405
- nil
406
- end
407
-
408
- confirmed
257
+ show_transfer_confirmation('Move', count, source_path, dest_path, '34')
409
258
  end
410
259
 
411
260
  def show_copy_confirmation(count, source_path, dest_path)
412
- source_display = shorten_path(source_path, 35)
413
- dest_display = shorten_path(dest_path, 35)
261
+ show_transfer_confirmation('Copy', count, source_path, dest_path, '32')
262
+ end
414
263
 
264
+ def show_transfer_confirmation(action, count, source_path, dest_path, color)
415
265
  content_lines = [
416
- '',
417
- "Copy #{count} item(s)?",
418
- '',
419
- "From: #{source_display}",
420
- "To: #{dest_display}",
421
- '',
422
- ' [Y]es - Copy',
423
- ' [N]o - Cancel',
424
- ''
266
+ '', "#{action} #{count} item(s)?", '',
267
+ "From: #{shorten_path(source_path, 35)}",
268
+ "To: #{shorten_path(dest_path, 35)}", '',
269
+ " [Y]es - #{action}", ' [N]o - Cancel', ''
425
270
  ]
426
-
427
- confirmed = false
428
- show_overlay_dialog('Copy Confirmation', content_lines, {
271
+ confirm_dialog("#{action} Confirmation", content_lines, {
429
272
  width: CONFIRMATION_DIALOG_WIDTH,
430
273
  height: DIALOG_BORDER_HEIGHT + content_lines.length,
431
- border_color: "\e[32m",
432
- title_color: "\e[1;32m",
274
+ border_color: "\e[#{color}m",
275
+ title_color: "\e[1;#{color}m",
433
276
  content_color: "\e[37m"
434
- }) do
435
- loop do
436
- input = STDIN.getch.downcase
437
- case input
438
- when 'y'
439
- confirmed = true
440
- break
441
- when 'n', "\e", "\x03", 'q'
442
- confirmed = false
443
- break
444
- end
445
- end
446
- nil
447
- end
448
-
449
- confirmed
277
+ })
450
278
  end
451
279
 
452
280
  def show_deletion_result(success_count, total_count, error_messages = [])
@@ -484,14 +312,13 @@ module Rufio
484
312
  title_color: title_color,
485
313
  content_color: "\e[37m"
486
314
  }) do
487
- STDIN.getch
315
+ KeyInput.getch
488
316
  nil
489
317
  end
490
318
  end
491
319
 
492
320
  def shorten_path(path, max_length)
493
- return path if path.length <= max_length
494
- "...#{path[-(max_length - 3)..-1]}"
321
+ TextUtils.shorten_path_from_end(path, max_length)
495
322
  end
496
323
  end
497
324
  end
@@ -13,6 +13,9 @@ module Rufio
13
13
  CONTROL_CHAR_NEWLINE = 10
14
14
  CONTROL_CHAR_CARRIAGE_RETURN = 13
15
15
 
16
+ # ZIPプレビューで一覧表示するエントリ数の上限
17
+ ARCHIVE_PREVIEW_ENTRIES = 200
18
+
16
19
  def initialize
17
20
  # future: hold syntax highlight settings etc.
18
21
  end
@@ -25,6 +28,8 @@ module Rufio
25
28
  return empty_response if file_size == 0
26
29
 
27
30
  begin
31
+ return archive_response(file_path, max_lines) if zip_archive?(file_path)
32
+
28
33
  # binary file detection
29
34
  sample = File.binread(file_path, [file_size, BINARY_SAMPLE_SIZE].min)
30
35
  return binary_response(file_path) if binary_file?(sample)
@@ -49,6 +54,45 @@ module Rufio
49
54
 
50
55
  private
51
56
 
57
+ def zip_archive?(file_path)
58
+ File.extname(file_path).casecmp?('.zip') || ZipArchive.zip_signature?(file_path)
59
+ end
60
+
61
+ # unzip -l 相当のエントリ一覧をプレビュー行として返す
62
+ def archive_response(file_path, max_lines)
63
+ limit = [max_lines, ARCHIVE_PREVIEW_ENTRIES].max
64
+ listing = ZipArchive.list(file_path, limit: limit)
65
+ total_size = listing.entries.sum(&:size)
66
+
67
+ lines = ["ZIP archive: #{listing.total_entries} entries#{' (ZIP64)' if listing.zip64?}", '']
68
+ lines << format('%12s %-8s %-16s %s', 'Length', 'Method', 'Modified', 'Name')
69
+ lines << format('%12s %-8s %-16s %s', '-' * 10, '-' * 8, '-' * 16, '-' * 4)
70
+ listing.entries.each do |entry|
71
+ modified = entry.mtime ? entry.mtime.strftime('%Y-%m-%d %H:%M') : '-'
72
+ method = entry.encrypted? ? "#{ZipArchive.method_name(entry.method)}*" : ZipArchive.method_name(entry.method)
73
+ lines << format('%12d %-8s %-16s %s', entry.size, method, modified, entry.name)
74
+ end
75
+ lines << format('%12s', '-' * 10)
76
+ if listing.truncated?
77
+ lines << "... #{listing.total_entries - listing.entries.length} more entries"
78
+ else
79
+ lines << format('%12d %d files', total_size, listing.entries.length)
80
+ end
81
+ lines << "Comment: #{listing.comment}" unless listing.comment.empty?
82
+
83
+ {
84
+ type: "archive",
85
+ language: nil,
86
+ lines: lines,
87
+ truncated: listing.truncated?,
88
+ size: File.size(file_path),
89
+ modified: File.mtime(file_path),
90
+ encoding: "UTF-8"
91
+ }
92
+ rescue ZipArchive::FormatError => e
93
+ error_response("#{ConfigLoader.message('file.invalid_archive')}: #{e.message}")
94
+ end
95
+
52
96
  def binary_file?(sample)
53
97
  return false if sample.empty?
54
98
 
@@ -77,7 +121,7 @@ module Rufio
77
121
  end
78
122
 
79
123
  lines << line.chomp
80
- rescue EncodingError, ArgumentError => e
124
+ rescue EncodingError, ArgumentError
81
125
  # If encoding fails, add placeholder
82
126
  lines << "[encoding error in line #{index + 1}]"
83
127
  end
@@ -104,7 +148,7 @@ module Rufio
104
148
  # Ensure line is properly encoded as UTF-8
105
149
  line = line.encode('UTF-8', invalid: :replace, undef: :replace, replace: '?')
106
150
  lines << line.chomp
107
- rescue EncodingError, ArgumentError => e
151
+ rescue EncodingError, ArgumentError
108
152
  # If encoding fails, add placeholder
109
153
  lines << "[encoding error in line #{index + 1}]"
110
154
  end
@@ -3,19 +3,36 @@
3
3
  require 'thread'
4
4
  require_relative 'text_utils'
5
5
  require_relative 'config_loader'
6
+ require_relative 'document_text_extractor'
7
+ require_relative 'native_content_search'
6
8
 
7
9
  module Rufio
8
10
  # ブックマーク配下のファイル名・テキスト内容をバックグラウンドで横断検索する。
9
11
  class GlobalSearch
10
12
  SPINNER_FRAMES = ['|', '/', '-', '\\'].freeze
11
13
  SPINNER_INTERVAL = 0.12
14
+ # 検索語に一致した部分の表示(黒の太字・黄色背景)
15
+ MATCH_FG = "\e[30m\e[1m"
16
+ MATCH_BG = "\e[43m"
12
17
 
13
18
  attr_reader :query, :results, :errors, :selected_index, :search_type, :skipped_files
14
19
 
20
+ class << self
21
+ # nil: 利用可能なら Zig 実装を使う(RUFIO_NATIVE_SEARCH=0 で無効化)
22
+ attr_writer :native_search
23
+
24
+ def native_search?
25
+ enabled = @native_search.nil? ? ENV['RUFIO_NATIVE_SEARCH'] != '0' : @native_search
26
+ enabled && NativeContentSearch.available?
27
+ end
28
+ end
29
+
15
30
  def initialize
16
31
  @active = false
17
32
  @scanning = false
18
33
  @worker = nil
34
+ @document_extractor = DocumentTextExtractor.new
35
+ @document_cache = DocumentTextCache.new(extractor: @document_extractor)
19
36
  end
20
37
 
21
38
  def active?
@@ -63,8 +80,12 @@ module Rufio
63
80
  when :entry
64
81
  @entries << value
65
82
  @results << value if @search_type == :content || matches_query?(value)
83
+ when :entries
84
+ # Zig 実装の結果はまとめて届くので、1 フレーム 256 件の上限に縛られない
85
+ @entries.concat(value)
86
+ @results.concat(value)
66
87
  when :error then @errors << value
67
- when :skipped then @skipped_files += 1
88
+ when :skipped then @skipped_files += value || 1
68
89
  when :done then @scanning = false
69
90
  end
70
91
  end
@@ -118,11 +139,20 @@ module Rufio
118
139
  first = [@selected_index - capacity + 1, 0].max
119
140
  @results.slice(first, capacity).to_a.each_with_index do |entry, index|
120
141
  selected = first + index == @selected_index
121
- text = "#{selected ? '>' : ' '} [#{entry[:bookmark]}] #{entry[:relative_path]}"
122
- text += ":#{entry[:line_number]}" if entry[:line_number]
142
+ prefix = "#{selected ? '>' : ' '} [#{entry[:bookmark]}] "
143
+ text = "#{prefix}#{entry[:relative_path]}"
144
+ text += if entry[:location]
145
+ " - #{entry[:location]}"
146
+ elsif entry[:line_number]
147
+ ":#{entry[:line_number]}"
148
+ else
149
+ ''
150
+ end
123
151
  y = index * row_height + 3
124
- write_line(screen, y, text, width, selected ? "\e[1;36m" : nil)
125
- write_line(screen, y + 1, " #{entry[:match_text]}", width) if entry[:line_number]
152
+ # ファイル名検索はパス、本文検索はスニペットの一致箇所をハイライトする
153
+ path_highlight = @search_type == :filename ? prefix.length : nil
154
+ write_line(screen, y, text, width, selected ? "\e[1;36m" : nil, highlight_from: path_highlight)
155
+ write_line(screen, y + 1, " #{entry[:match_text]}", width, highlight_from: 4) if entry[:match_text]
126
156
  end
127
157
  if @results.empty?
128
158
  message = if @bookmark_count.zero?
@@ -160,9 +190,14 @@ module Rufio
160
190
  return unless @scanning
161
191
 
162
192
  roots, events, type, query = @roots, @events, @search_type, @query.downcase
193
+ native_cache = self.class.native_search? ? (@native_cache ||= NativeContentSearch::Cache.new) : nil
163
194
  @worker = Thread.new do
164
195
  begin
165
- scan_roots(roots, events, type, query)
196
+ if native_cache && type == :content && NativeContentSearch.supports_query?(query)
197
+ scan_roots_natively(roots, events, query, native_cache)
198
+ else
199
+ scan_roots(roots, events, type, query)
200
+ end
166
201
  rescue StandardError => e
167
202
  events << [:error, e.message]
168
203
  end
@@ -230,7 +265,58 @@ module Rufio
230
265
  end
231
266
  end
232
267
 
268
+ # Zig 実装で走査・検索し、PDF などのドキュメントだけ Ruby 側で抽出する
269
+ def scan_roots_natively(roots, events, query, cache)
270
+ valid_roots = []
271
+ roots.each do |name, root|
272
+ root = File.expand_path(root)
273
+ raise Errno::ENOTDIR, root unless File.directory?(root)
274
+
275
+ valid_roots << [name, root]
276
+ rescue SystemCallError, IOError => e
277
+ events << [:error, "#{name}: #{e.message}"]
278
+ end
279
+ return if valid_roots.empty?
280
+
281
+ engine = NativeContentSearch.new(roots: valid_roots.map(&:last), query: query,
282
+ excluded_extensions: @excluded_extensions,
283
+ document_extensions: DocumentTextExtractor::EXTENSIONS, cache: cache)
284
+ engine.each_batch do |batch|
285
+ entries = []
286
+ batch.each do |kind, *args|
287
+ case kind
288
+ when :match, :document
289
+ name, root = valid_roots[args[0]]
290
+ path = args[1]
291
+ entry = { path: path, bookmark: name, relative_path: path.delete_prefix(root + File::SEPARATOR) }
292
+ if kind == :document
293
+ scan_content(entry, query, events)
294
+ else
295
+ entries << entry.merge(line_number: args[2], match_text: args[3])
296
+ end
297
+ when :error then events << [:error, args[1]]
298
+ when :skipped then events << [:skipped, args[0]]
299
+ end
300
+ end
301
+ events << [:entries, entries] unless entries.empty?
302
+ end
303
+ ensure
304
+ engine&.close
305
+ end
306
+
233
307
  def scan_content(entry, query, events)
308
+ if @document_extractor.supported?(entry[:path])
309
+ @document_cache.each_record(entry[:path]) do |record|
310
+ match_index = record[:search_text].index(query)
311
+ next unless match_index
312
+
313
+ snippet = record[:text][[match_index - 30, 0].max, 200].to_s.strip
314
+ metadata = record.reject { |key, _| key == :text || key == :search_text }
315
+ events << [:entry, entry.merge(metadata).merge(match_text: snippet)]
316
+ end
317
+ return
318
+ end
319
+
234
320
  File.open(entry[:path], 'rb') do |file|
235
321
  sample = file.read(8192).to_s
236
322
  if sample.include?("\0")
@@ -249,13 +335,39 @@ module Rufio
249
335
  events << [:entry, entry.merge(line_number: number, match_text: snippet)]
250
336
  end
251
337
  end
252
- rescue SystemCallError, IOError => e
338
+ rescue SystemCallError, IOError, DocumentTextExtractor::ExtractionError => e
253
339
  events << [:error, "#{entry[:path]}: #{e.message}"]
254
340
  end
255
341
 
256
- def write_line(screen, y, text, width, color = nil)
342
+ def write_line(screen, y, text, width, color = nil, highlight_from: nil)
257
343
  safe_text = text.scrub.gsub(/[[:cntrl:]]/, ' ')
258
- screen.put_string(0, y, TextUtils.truncate_to_width(safe_text, width), fg: color)
344
+ visible = TextUtils.truncate_to_width(safe_text, width)
345
+ screen.put_string(0, y, visible, fg: color)
346
+ return unless highlight_from
347
+
348
+ match_ranges(visible, highlight_from).each do |start, length|
349
+ x = TextUtils.display_width(visible[0, start])
350
+ screen.put_string(x, y, visible[start, length], fg: MATCH_FG, bg: MATCH_BG)
351
+ end
352
+ end
353
+
354
+ # 検索と同じく大文字小文字を区別せず、from 文字目以降の一致位置を [開始, 長さ] で返す
355
+ def match_ranges(text, from)
356
+ needle = @query.to_s.downcase.chars
357
+ return [] if needle.empty?
358
+
359
+ folded = text.chars.map(&:downcase)
360
+ ranges = []
361
+ index = from
362
+ while index + needle.length <= folded.length
363
+ if folded[index, needle.length] == needle
364
+ ranges << [index, needle.length]
365
+ index += needle.length
366
+ else
367
+ index += 1
368
+ end
369
+ end
370
+ ranges
259
371
  end
260
372
  end
261
373
  end
@@ -62,6 +62,7 @@ module Rufio
62
62
  '', 'Selection and file operations',
63
63
  "#{key(:select)}: Select / unselect file",
64
64
  "#{key(:copy_selected)}/#{key(:move_selected)}/#{key(:delete_selected)}: Copy / move / delete selected files",
65
+ "#{key(:zoxide)}: Extract selected .zip files / compress selected items",
65
66
  "#{key(:rename)}/#{key(:delete)}: Rename / delete current file",
66
67
  "#{key(:create_file)}/#{key(:create_dir)}: Create file / directory",
67
68
  '', 'Search and bookmarks',
@@ -72,8 +73,9 @@ module Rufio
72
73
  "#{key(:add_bookmark)}/#{key(:bookmark_menu)}: Add / manage bookmarks",
73
74
  'Tab / Shift+Tab: Next / previous bookmark',
74
75
  "1-9: Jump to bookmark | #{key(:start_dir)}: Start directory",
75
- "#{key(:zoxide)}: Directory history (zoxide)",
76
- "#{key(:quit)}: Quit (with confirmation)"
76
+ "#{key(:zoxide)}: Directory history (zoxide, when nothing is selected)",
77
+ "#{key(:quit)}: Quit (with confirmation)",
78
+ "#{key(:quit_cd)}: Quit and cd to the current directory (rufio --shell-init)"
77
79
  ]
78
80
  when 'Modes'
79
81
  [
@@ -87,6 +89,10 @@ module Rufio
87
89
  'Search file names or text inside every bookmarked directory.',
88
90
  'Tab: Switch File names / Content (literal, case insensitive)',
89
91
  'Content results show file, line number and matching text.',
92
+ 'Documents: .pdf (page), .xlsx (sheet/cell), .pptx (slide)',
93
+ 'Ruby extracts document text; no Python or Office is needed.',
94
+ 'Unchanged document text is cached in memory for faster searches.',
95
+ 'Images need OCR; legacy .xls/.ppt and slide notes are unsupported.',
90
96
  'Yellow Searching... + spinner: Search is running.',
91
97
  'Green Search complete: All bookmarked directories scanned.',
92
98
  'Exclude extensions in ~/.config/rufio/config.rb:',