rufio 0.32.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'time'
5
+
6
+ module Rufio
7
+ # コマンド実行ログを保存・管理するクラス
8
+ class CommandLogger
9
+ attr_reader :log_dir
10
+
11
+ # 初期化
12
+ # @param log_dir [String] ログディレクトリのパス
13
+ def initialize(log_dir)
14
+ @log_dir = log_dir
15
+ FileUtils.mkdir_p(@log_dir) unless Dir.exist?(@log_dir)
16
+ end
17
+
18
+ # コマンド実行ログを保存
19
+ # @param command [String] 実行したコマンド
20
+ # @param output [String] コマンドの出力
21
+ # @param success [Boolean] 実行が成功したかどうか
22
+ # @param error [String, nil] エラーメッセージ(失敗時)
23
+ def log(command, output, success:, error: nil)
24
+ timestamp = Time.now
25
+ filename = generate_filename(command, timestamp)
26
+ filepath = File.join(@log_dir, filename)
27
+
28
+ content = format_log_content(command, output, timestamp, success, error)
29
+
30
+ File.write(filepath, content)
31
+ end
32
+
33
+ # ログファイル一覧を取得(新しい順)
34
+ # @return [Array<String>] ログファイルのパス一覧
35
+ def list_logs
36
+ Dir.glob(File.join(@log_dir, "*.log")).sort.reverse
37
+ end
38
+
39
+ # 古いログを削除
40
+ # @param max_logs [Integer] 保管する最大ログ数
41
+ def cleanup_old_logs(max_logs:)
42
+ logs = list_logs
43
+ return if logs.size <= max_logs
44
+
45
+ logs_to_delete = logs[max_logs..-1]
46
+ logs_to_delete.each do |log_file|
47
+ File.delete(log_file)
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ # ログファイル名を生成
54
+ # @param command [String] コマンド
55
+ # @param timestamp [Time] タイムスタンプ
56
+ # @return [String] ファイル名
57
+ def generate_filename(command, timestamp)
58
+ # ミリ秒を含めて一意性を確保
59
+ timestamp_str = timestamp.strftime("%Y%m%d%H%M%S") + sprintf("%03d", (timestamp.usec / 1000).to_i)
60
+ command_part = sanitize_command(command)
61
+ "#{timestamp_str}-#{command_part}.log"
62
+ end
63
+
64
+ # コマンド文字列をファイル名用にサニタイズ
65
+ # @param command [String] コマンド
66
+ # @return [String] サニタイズされたコマンド
67
+ def sanitize_command(command)
68
+ # Remove ! prefix if exists
69
+ cmd = command.start_with?('!') ? command[1..-1] : command
70
+
71
+ # Take first word (command name)
72
+ cmd = cmd.split.first || 'command'
73
+
74
+ # Remove unsafe characters
75
+ cmd = cmd.gsub(/[^\w\-]/, '_')
76
+
77
+ # Limit length
78
+ cmd[0...50]
79
+ end
80
+
81
+ # ログ内容をフォーマット
82
+ # @param command [String] コマンド
83
+ # @param output [String] 出力
84
+ # @param timestamp [Time] タイムスタンプ
85
+ # @param success [Boolean] 成功フラグ
86
+ # @param error [String, nil] エラーメッセージ
87
+ # @return [String] フォーマットされたログ内容
88
+ def format_log_content(command, output, timestamp, success, error)
89
+ lines = []
90
+ lines << "=" * 80
91
+ lines << "Command Execution Log"
92
+ lines << "=" * 80
93
+ lines << ""
94
+ lines << "Timestamp: #{timestamp.strftime('%Y-%m-%d %H:%M:%S')}"
95
+ lines << "Command: #{command}"
96
+ lines << "Status: #{success ? 'Success' : 'Failed'}"
97
+ lines << ""
98
+
99
+ if error
100
+ lines << "Error:"
101
+ lines << error
102
+ lines << ""
103
+ end
104
+
105
+ if output && !output.empty?
106
+ lines << "Output:"
107
+ lines << "-" * 80
108
+ lines << output
109
+ lines << "-" * 80
110
+ end
111
+
112
+ lines << ""
113
+ lines << "=" * 80
114
+
115
+ lines.join("\n")
116
+ end
117
+ end
118
+ end
@@ -5,8 +5,11 @@ require 'open3'
5
5
  module Rufio
6
6
  # コマンドモード - プラグインコマンドを実行するためのインターフェース
7
7
  class CommandMode
8
- def initialize
8
+ attr_accessor :background_executor
9
+
10
+ def initialize(background_executor = nil)
9
11
  @commands = {}
12
+ @background_executor = background_executor
10
13
  load_plugin_commands
11
14
  end
12
15
 
@@ -17,7 +20,19 @@ module Rufio
17
20
 
18
21
  # シェルコマンドの実行 (! で始まる場合)
19
22
  if command_string.strip.start_with?('!')
20
- return execute_shell_command(command_string.strip[1..-1])
23
+ shell_command = command_string.strip[1..-1]
24
+
25
+ # バックグラウンドエグゼキュータが利用可能な場合は非同期実行
26
+ if @background_executor
27
+ if @background_executor.execute_async(shell_command)
28
+ return "🔄 バックグラウンドで実行中: #{shell_command.split.first}"
29
+ else
30
+ return "⚠️ 既にコマンドが実行中です"
31
+ end
32
+ else
33
+ # バックグラウンドエグゼキュータがない場合は同期実行
34
+ return execute_shell_command(shell_command)
35
+ end
21
36
  end
22
37
 
23
38
  # コマンド名を取得 (前後の空白を削除)
@@ -22,18 +22,11 @@ module Rufio
22
22
 
23
23
  @entries = []
24
24
 
25
- Dir.entries(@current_path).each do |name|
26
- next if name == '.'
27
-
28
- full_path = File.join(@current_path, name)
29
- entry = {
30
- name: name,
31
- path: full_path,
32
- type: determine_file_type(full_path),
33
- size: safe_file_size(full_path),
34
- modified: safe_file_mtime(full_path)
35
- }
36
- @entries << entry
25
+ # NativeScannerが利用可能な場合は使用
26
+ if use_native_scanner?
27
+ scan_with_native_scanner
28
+ else
29
+ scan_with_ruby
37
30
  end
38
31
 
39
32
  sort_entries!
@@ -84,6 +77,61 @@ module Rufio
84
77
 
85
78
  private
86
79
 
80
+ # NativeScannerを使用するかどうか
81
+ def use_native_scanner?
82
+ defined?(Rufio::NativeScanner) && Rufio::NativeScanner.mode != 'ruby'
83
+ end
84
+
85
+ # NativeScannerでスキャン
86
+ def scan_with_native_scanner
87
+ entries = Rufio::NativeScanner.scan_directory(@current_path)
88
+
89
+ entries.each do |entry|
90
+ next if entry[:name] == '.'
91
+
92
+ full_path = File.join(@current_path, entry[:name])
93
+
94
+ # NativeScannerのエントリをDirectoryListingの形式に変換
95
+ converted_entry = {
96
+ name: entry[:name],
97
+ path: full_path,
98
+ type: convert_type(entry[:type], entry[:executable]),
99
+ size: entry[:size] || 0,
100
+ modified: entry[:mtime] ? Time.at(entry[:mtime]) : Time.now
101
+ }
102
+ @entries << converted_entry
103
+ end
104
+ rescue StandardError => e
105
+ # エラーが発生した場合はRubyでスキャン
106
+ Logger.debug "NativeScanner failed: #{e.message}, falling back to Ruby" if defined?(Logger)
107
+ scan_with_ruby
108
+ end
109
+
110
+ # Rubyの標準機能でスキャン
111
+ def scan_with_ruby
112
+ Dir.entries(@current_path).each do |name|
113
+ next if name == '.'
114
+
115
+ full_path = File.join(@current_path, name)
116
+ entry = {
117
+ name: name,
118
+ path: full_path,
119
+ type: determine_file_type(full_path),
120
+ size: safe_file_size(full_path),
121
+ modified: safe_file_mtime(full_path)
122
+ }
123
+ @entries << entry
124
+ end
125
+ end
126
+
127
+ # NativeScannerのタイプをDirectoryListingのタイプに変換
128
+ def convert_type(type, executable)
129
+ return type if type == 'directory'
130
+ return 'executable' if executable
131
+
132
+ 'file'
133
+ end
134
+
87
135
  def determine_file_type(path)
88
136
  return 'directory' if File.directory?(path)
89
137
  return 'executable' if File.executable?(path) && !File.directory?(path)
@@ -56,6 +56,11 @@ module Rufio
56
56
  @in_help_mode = false
57
57
  @pre_help_directory = nil
58
58
 
59
+ # Log viewer mode
60
+ @in_log_viewer_mode = false
61
+ @pre_log_viewer_directory = nil
62
+ @log_dir = File.join(Dir.home, '.config', 'rufio', 'log')
63
+
59
64
  # Preview pane focus and scroll
60
65
  @preview_focused = false
61
66
  @preview_scroll_offset = 0
@@ -103,6 +108,11 @@ module Rufio
103
108
  return exit_help_mode
104
109
  end
105
110
 
111
+ # ログビューワモード中のESCキー特別処理
112
+ if @in_log_viewer_mode && key == "\e"
113
+ return exit_log_viewer_mode
114
+ end
115
+
106
116
  # フィルターモード中は他のキーバインドを無効化
107
117
  return handle_filter_input(key) if @filter_manager.filter_mode
108
118
 
@@ -167,7 +177,7 @@ module Rufio
167
177
  copy_selected_to_current
168
178
  when 'x' # x - delete selected files
169
179
  delete_selected_files
170
- when 'p' # p - project mode
180
+ when 'P' # P - project mode
171
181
  enter_project_mode
172
182
  when 'b' # b - add bookmark
173
183
  add_bookmark
@@ -179,6 +189,8 @@ module Rufio
179
189
  goto_bookmark(key.to_i)
180
190
  when '?' # ? - enter help mode
181
191
  enter_help_mode
192
+ when 'L' # L - enter log viewer mode
193
+ enter_log_viewer_mode
182
194
  when ':' # : - command mode
183
195
  activate_command_mode
184
196
  else
@@ -255,6 +267,49 @@ module Rufio
255
267
  true
256
268
  end
257
269
 
270
+ # ログビューワモード関連メソッド
271
+
272
+ # ログビューワモード中かどうか
273
+ def log_viewer_mode?
274
+ @in_log_viewer_mode
275
+ end
276
+
277
+ # ログビューワモードに入る
278
+ def enter_log_viewer_mode
279
+ return false unless @directory_listing
280
+
281
+ # 現在のディレクトリを保存
282
+ @pre_log_viewer_directory = @directory_listing.current_path
283
+
284
+ # log ディレクトリを作成(存在しない場合)
285
+ FileUtils.mkdir_p(@log_dir) unless Dir.exist?(@log_dir)
286
+
287
+ # log ディレクトリに移動
288
+ navigate_to_directory(@log_dir)
289
+
290
+ # ログビューワモードを有効化
291
+ @in_log_viewer_mode = true
292
+
293
+ true
294
+ end
295
+
296
+ # ログビューワモードを終了
297
+ def exit_log_viewer_mode
298
+ return false unless @in_log_viewer_mode
299
+ return false unless @pre_log_viewer_directory
300
+
301
+ # ログビューワモードを無効化
302
+ @in_log_viewer_mode = false
303
+
304
+ # 元のディレクトリに戻る
305
+ navigate_to_directory(@pre_log_viewer_directory)
306
+
307
+ # 保存したディレクトリをクリア
308
+ @pre_log_viewer_directory = nil
309
+
310
+ true
311
+ end
312
+
258
313
  # ヘルプモード時の制限付き親ディレクトリナビゲーション
259
314
  def navigate_parent_with_restriction
260
315
  if @in_help_mode
@@ -276,8 +331,24 @@ module Rufio
276
331
 
277
332
  # info ディレクトリ配下であれば、通常のナビゲーションを実行
278
333
  navigate_parent
334
+ elsif @in_log_viewer_mode
335
+ # log ディレクトリより上には移動できない
336
+ current_path = @directory_listing.current_path
337
+
338
+ # 現在のパスが log ディレクトリ以下でない場合は移動を許可しない
339
+ unless current_path.start_with?(@log_dir)
340
+ return false
341
+ end
342
+
343
+ # 現在のパスが log ディレクトリそのものの場合は移動を許可しない
344
+ if current_path == @log_dir
345
+ return false
346
+ end
347
+
348
+ # log ディレクトリ配下であれば、通常のナビゲーションを実行
349
+ navigate_parent
279
350
  else
280
- # ヘルプモード外では通常のナビゲーション
351
+ # ヘルプモード・ログビューワモード外では通常のナビゲーション
281
352
  navigate_parent
282
353
  end
283
354
  end
Binary file
@@ -0,0 +1,306 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ffi'
4
+ require 'json'
5
+
6
+ module Rufio
7
+ # NativeScanner - Rust/Goのネイティブライブラリを使った高速ディレクトリスキャナー
8
+ class NativeScanner
9
+ # ライブラリパス
10
+ LIB_DIR = File.expand_path('native', __dir__)
11
+ RUST_LIB = File.join(LIB_DIR, 'librufio_scanner.dylib')
12
+ GO_LIB = File.join(LIB_DIR, 'libscanner.dylib')
13
+
14
+ @mode = nil
15
+ @current_library = nil
16
+
17
+ # Rustライブラリ用のFFIモジュール
18
+ module RustLib
19
+ extend FFI::Library
20
+
21
+ begin
22
+ ffi_lib RUST_LIB
23
+ attach_function :scan_directory, [:string], :pointer
24
+ attach_function :scan_directory_fast, [:string, :int], :pointer
25
+ attach_function :get_version, [], :pointer
26
+ @available = true
27
+ rescue LoadError, FFI::NotFoundError
28
+ @available = false
29
+ end
30
+
31
+ def self.available?
32
+ @available
33
+ end
34
+ end
35
+
36
+ # Goライブラリ用のFFIモジュール
37
+ module GoLib
38
+ extend FFI::Library
39
+
40
+ begin
41
+ ffi_lib GO_LIB
42
+ attach_function :ScanDirectory, [:string], :pointer
43
+ attach_function :ScanDirectoryFast, [:string, :int], :pointer
44
+ attach_function :GetVersion, [], :pointer
45
+ attach_function :FreeCString, [:pointer], :void
46
+ @available = true
47
+ rescue LoadError, FFI::NotFoundError
48
+ @available = false
49
+ end
50
+
51
+ def self.available?
52
+ @available
53
+ end
54
+ end
55
+
56
+ class << self
57
+ # モード設定
58
+ def mode=(value)
59
+ case value
60
+ when 'rust'
61
+ if RustLib.available?
62
+ @mode = 'rust'
63
+ @current_library = RustLib
64
+ else
65
+ @mode = 'ruby'
66
+ @current_library = nil
67
+ end
68
+ when 'go'
69
+ if GoLib.available?
70
+ @mode = 'go'
71
+ @current_library = GoLib
72
+ else
73
+ @mode = 'ruby'
74
+ @current_library = nil
75
+ end
76
+ when 'auto'
77
+ # 優先順位: Rust > Go > Ruby
78
+ if RustLib.available?
79
+ @mode = 'rust'
80
+ @current_library = RustLib
81
+ elsif GoLib.available?
82
+ @mode = 'go'
83
+ @current_library = GoLib
84
+ else
85
+ @mode = 'ruby'
86
+ @current_library = nil
87
+ end
88
+ when 'ruby'
89
+ @mode = 'ruby'
90
+ @current_library = nil
91
+ else
92
+ # 無効なモードはrubyにフォールバック
93
+ @mode = 'ruby'
94
+ @current_library = nil
95
+ end
96
+ end
97
+
98
+ # 現在のモード取得
99
+ def mode
100
+ # 初回アクセス時はautoモードに設定
101
+ self.mode = 'auto' if @mode.nil?
102
+ @mode
103
+ end
104
+
105
+ # 利用可能なライブラリをチェック
106
+ def available_libraries
107
+ {
108
+ rust: RustLib.available?,
109
+ go: GoLib.available?
110
+ }
111
+ end
112
+
113
+ # ディレクトリをスキャン
114
+ def scan_directory(path)
115
+ # モードが未設定の場合は自動設定
116
+ mode if @mode.nil?
117
+
118
+ case @mode
119
+ when 'rust'
120
+ scan_with_rust(path)
121
+ when 'go'
122
+ scan_with_go(path)
123
+ else
124
+ scan_with_ruby(path)
125
+ end
126
+ end
127
+
128
+ # 高速スキャン(エントリ数制限付き)
129
+ def scan_directory_fast(path, max_entries = 1000)
130
+ # モードが未設定の場合は自動設定
131
+ mode if @mode.nil?
132
+
133
+ case @mode
134
+ when 'rust'
135
+ scan_fast_with_rust(path, max_entries)
136
+ when 'go'
137
+ scan_fast_with_go(path, max_entries)
138
+ else
139
+ scan_fast_with_ruby(path, max_entries)
140
+ end
141
+ end
142
+
143
+ # バージョン情報取得
144
+ def version
145
+ # モードが未設定の場合は自動設定
146
+ mode if @mode.nil?
147
+
148
+ case @mode
149
+ when 'rust'
150
+ ptr = RustLib.get_version
151
+ ptr.read_string
152
+ when 'go'
153
+ ptr = GoLib.GetVersion
154
+ result = ptr.read_string
155
+ GoLib.FreeCString(ptr)
156
+ result
157
+ else
158
+ "Ruby #{RUBY_VERSION}"
159
+ end
160
+ end
161
+
162
+ private
163
+
164
+ # Rustライブラリでスキャン
165
+ def scan_with_rust(path)
166
+ raise StandardError, "Directory does not exist: #{path}" unless Dir.exist?(path)
167
+
168
+ ptr = RustLib.scan_directory(path)
169
+ json_str = ptr.read_string
170
+ parse_scan_result(json_str)
171
+ rescue StandardError => e
172
+ raise StandardError, "Rust scan failed: #{e.message}"
173
+ end
174
+
175
+ # Rustライブラリで高速スキャン
176
+ def scan_fast_with_rust(path, max_entries)
177
+ raise StandardError, "Directory does not exist: #{path}" unless Dir.exist?(path)
178
+
179
+ ptr = RustLib.scan_directory_fast(path, max_entries)
180
+ json_str = ptr.read_string
181
+ parse_scan_result(json_str)
182
+ rescue StandardError => e
183
+ raise StandardError, "Rust fast scan failed: #{e.message}"
184
+ end
185
+
186
+ # Goライブラリでスキャン
187
+ def scan_with_go(path)
188
+ raise StandardError, "Directory does not exist: #{path}" unless Dir.exist?(path)
189
+
190
+ ptr = GoLib.ScanDirectory(path)
191
+ json_str = ptr.read_string
192
+ GoLib.FreeCString(ptr)
193
+ parse_scan_result(json_str)
194
+ rescue StandardError => e
195
+ raise StandardError, "Go scan failed: #{e.message}"
196
+ end
197
+
198
+ # Goライブラリで高速スキャン
199
+ def scan_fast_with_go(path, max_entries)
200
+ raise StandardError, "Directory does not exist: #{path}" unless Dir.exist?(path)
201
+
202
+ ptr = GoLib.ScanDirectoryFast(path, max_entries)
203
+ json_str = ptr.read_string
204
+ GoLib.FreeCString(ptr)
205
+ parse_scan_result(json_str)
206
+ rescue StandardError => e
207
+ raise StandardError, "Go fast scan failed: #{e.message}"
208
+ end
209
+
210
+ # Rubyでスキャン(フォールバック実装)
211
+ def scan_with_ruby(path)
212
+ raise StandardError, "Directory does not exist: #{path}" unless Dir.exist?(path)
213
+
214
+ entries = []
215
+ Dir.foreach(path) do |entry|
216
+ next if entry == '.' || entry == '..'
217
+
218
+ full_path = File.join(path, entry)
219
+ stat = File.lstat(full_path)
220
+
221
+ entries << {
222
+ name: entry,
223
+ type: file_type(stat),
224
+ size: stat.size,
225
+ mtime: stat.mtime.to_i,
226
+ mode: stat.mode
227
+ }
228
+ end
229
+ entries
230
+ rescue StandardError => e
231
+ raise StandardError, "Ruby scan failed: #{e.message}"
232
+ end
233
+
234
+ # Ruby高速スキャン(エントリ数制限付き)
235
+ def scan_fast_with_ruby(path, max_entries)
236
+ raise StandardError, "Directory does not exist: #{path}" unless Dir.exist?(path)
237
+
238
+ entries = []
239
+ count = 0
240
+
241
+ Dir.foreach(path) do |entry|
242
+ next if entry == '.' || entry == '..'
243
+ break if count >= max_entries
244
+
245
+ full_path = File.join(path, entry)
246
+ stat = File.lstat(full_path)
247
+
248
+ entries << {
249
+ name: entry,
250
+ type: file_type(stat),
251
+ size: stat.size,
252
+ mtime: stat.mtime.to_i,
253
+ mode: stat.mode
254
+ }
255
+ count += 1
256
+ end
257
+ entries
258
+ rescue StandardError => e
259
+ raise StandardError, "Ruby fast scan failed: #{e.message}"
260
+ end
261
+
262
+ # ファイルタイプを判定
263
+ def file_type(stat)
264
+ if stat.directory?
265
+ 'directory'
266
+ elsif stat.symlink?
267
+ 'symlink'
268
+ elsif stat.file?
269
+ 'file'
270
+ else
271
+ 'other'
272
+ end
273
+ end
274
+
275
+ # JSONレスポンスをパース
276
+ def parse_scan_result(json_str)
277
+ entries = JSON.parse(json_str, symbolize_names: true)
278
+
279
+ # エラーチェック(配列ではなくハッシュが返された場合)
280
+ if entries.is_a?(Hash) && entries[:error]
281
+ raise StandardError, entries[:error]
282
+ end
283
+
284
+ # 配列が返された場合は各エントリを変換
285
+ if entries.is_a?(Array)
286
+ return entries.map do |entry|
287
+ {
288
+ name: entry[:name],
289
+ type: entry[:is_dir] ? 'directory' : 'file',
290
+ size: entry[:size],
291
+ mtime: entry[:mtime],
292
+ mode: 0, # Rustライブラリはmodeを返さない
293
+ executable: entry[:executable],
294
+ hidden: entry[:hidden]
295
+ }
296
+ end
297
+ end
298
+
299
+ # それ以外の場合は空配列
300
+ []
301
+ rescue JSON::ParserError => e
302
+ raise StandardError, "Failed to parse scan result: #{e.message}"
303
+ end
304
+ end
305
+ end
306
+ end