rufio 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +188 -0
  3. data/CHANGELOG_v0.4.0.md +146 -0
  4. data/CHANGELOG_v0.5.0.md +26 -0
  5. data/CHANGELOG_v0.6.0.md +182 -0
  6. data/CHANGELOG_v0.7.0.md +280 -0
  7. data/CHANGELOG_v0.8.0.md +267 -0
  8. data/CHANGELOG_v0.9.0.md +279 -0
  9. data/README.md +631 -0
  10. data/README_EN.md +561 -0
  11. data/Rakefile +156 -0
  12. data/bin/rufio +34 -0
  13. data/config_example.rb +88 -0
  14. data/docs/PLUGIN_GUIDE.md +431 -0
  15. data/docs/plugin_example.rb +119 -0
  16. data/lib/rufio/application.rb +32 -0
  17. data/lib/rufio/bookmark.rb +115 -0
  18. data/lib/rufio/bookmark_manager.rb +173 -0
  19. data/lib/rufio/color_helper.rb +150 -0
  20. data/lib/rufio/command_mode.rb +72 -0
  21. data/lib/rufio/command_mode_ui.rb +168 -0
  22. data/lib/rufio/config.rb +199 -0
  23. data/lib/rufio/config_loader.rb +110 -0
  24. data/lib/rufio/dialog_renderer.rb +127 -0
  25. data/lib/rufio/directory_listing.rb +113 -0
  26. data/lib/rufio/file_opener.rb +140 -0
  27. data/lib/rufio/file_operations.rb +231 -0
  28. data/lib/rufio/file_preview.rb +200 -0
  29. data/lib/rufio/filter_manager.rb +114 -0
  30. data/lib/rufio/health_checker.rb +246 -0
  31. data/lib/rufio/keybind_handler.rb +828 -0
  32. data/lib/rufio/logger.rb +103 -0
  33. data/lib/rufio/plugin.rb +89 -0
  34. data/lib/rufio/plugin_config.rb +59 -0
  35. data/lib/rufio/plugin_manager.rb +84 -0
  36. data/lib/rufio/plugins/file_operations.rb +44 -0
  37. data/lib/rufio/selection_manager.rb +79 -0
  38. data/lib/rufio/terminal_ui.rb +630 -0
  39. data/lib/rufio/text_utils.rb +108 -0
  40. data/lib/rufio/version.rb +5 -0
  41. data/lib/rufio/zoxide_integration.rb +188 -0
  42. data/lib/rufio.rb +33 -0
  43. data/publish_gem.zsh +131 -0
  44. data/rufio.gemspec +40 -0
  45. data/test_delete/test1.txt +1 -0
  46. data/test_delete/test2.txt +1 -0
  47. metadata +189 -0
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module Rufio
6
+ # Handles file operations (move, copy, delete)
7
+ class FileOperations
8
+ # Operation result structure
9
+ # @return [Hash] { success: Boolean, message: String, count: Integer }
10
+ OperationResult = Struct.new(:success, :message, :count, :errors, keyword_init: true)
11
+
12
+ # Move files/directories to destination
13
+ # @param items [Array<String>] Item names to move
14
+ # @param source_directory [String] Source directory path
15
+ # @param destination [String] Destination directory path
16
+ # @return [OperationResult]
17
+ def move(items, source_directory, destination)
18
+ perform_operation(:move, items, source_directory, destination)
19
+ end
20
+
21
+ # Copy files/directories to destination
22
+ # @param items [Array<String>] Item names to copy
23
+ # @param source_directory [String] Source directory path
24
+ # @param destination [String] Destination directory path
25
+ # @return [OperationResult]
26
+ def copy(items, source_directory, destination)
27
+ perform_operation(:copy, items, source_directory, destination)
28
+ end
29
+
30
+ # Delete files/directories
31
+ # @param items [Array<String>] Item names to delete
32
+ # @param source_directory [String] Source directory path
33
+ # @return [OperationResult]
34
+ def delete(items, source_directory)
35
+ success_count = 0
36
+ error_messages = []
37
+
38
+ items.each do |item_name|
39
+ item_path = File.join(source_directory, item_name)
40
+
41
+ begin
42
+ # Check if file/directory exists
43
+ unless File.exist?(item_path)
44
+ error_messages << "#{item_name}: File not found"
45
+ next
46
+ end
47
+
48
+ is_directory = File.directory?(item_path)
49
+
50
+ if is_directory
51
+ FileUtils.rm_rf(item_path)
52
+ else
53
+ FileUtils.rm(item_path)
54
+ end
55
+
56
+ # Verify deletion
57
+ sleep(0.01) # Wait for file system sync
58
+ if File.exist?(item_path)
59
+ error_messages << "#{item_name}: Deletion failed"
60
+ else
61
+ success_count += 1
62
+ end
63
+ rescue StandardError => e
64
+ error_messages << "#{item_name}: #{e.message}"
65
+ end
66
+ end
67
+
68
+ OperationResult.new(
69
+ success: error_messages.empty?,
70
+ message: build_delete_message(success_count, items.length),
71
+ count: success_count,
72
+ errors: error_messages
73
+ )
74
+ end
75
+
76
+ # Create a new file
77
+ # @param directory [String] Directory path
78
+ # @param filename [String] File name
79
+ # @return [OperationResult]
80
+ def create_file(directory, filename)
81
+ # Validate filename
82
+ if filename.include?('/') || filename.include?('\\')
83
+ return OperationResult.new(
84
+ success: false,
85
+ message: 'Invalid filename: cannot contain path separators',
86
+ count: 0,
87
+ errors: []
88
+ )
89
+ end
90
+
91
+ file_path = File.join(directory, filename)
92
+
93
+ # Check if file already exists
94
+ if File.exist?(file_path)
95
+ return OperationResult.new(
96
+ success: false,
97
+ message: 'File already exists',
98
+ count: 0,
99
+ errors: []
100
+ )
101
+ end
102
+
103
+ begin
104
+ File.write(file_path, '')
105
+ OperationResult.new(
106
+ success: true,
107
+ message: "File created: #{filename}",
108
+ count: 1,
109
+ errors: []
110
+ )
111
+ rescue StandardError => e
112
+ OperationResult.new(
113
+ success: false,
114
+ message: "Creation error: #{e.message}",
115
+ count: 0,
116
+ errors: [e.message]
117
+ )
118
+ end
119
+ end
120
+
121
+ # Create a new directory
122
+ # @param parent_directory [String] Parent directory path
123
+ # @param dirname [String] Directory name
124
+ # @return [OperationResult]
125
+ def create_directory(parent_directory, dirname)
126
+ # Validate dirname
127
+ if dirname.include?('/') || dirname.include?('\\')
128
+ return OperationResult.new(
129
+ success: false,
130
+ message: 'Invalid directory name: cannot contain path separators',
131
+ count: 0,
132
+ errors: []
133
+ )
134
+ end
135
+
136
+ dir_path = File.join(parent_directory, dirname)
137
+
138
+ # Check if directory already exists
139
+ if File.exist?(dir_path)
140
+ return OperationResult.new(
141
+ success: false,
142
+ message: 'Directory already exists',
143
+ count: 0,
144
+ errors: []
145
+ )
146
+ end
147
+
148
+ begin
149
+ Dir.mkdir(dir_path)
150
+ OperationResult.new(
151
+ success: true,
152
+ message: "Directory created: #{dirname}",
153
+ count: 1,
154
+ errors: []
155
+ )
156
+ rescue StandardError => e
157
+ OperationResult.new(
158
+ success: false,
159
+ message: "Creation error: #{e.message}",
160
+ count: 0,
161
+ errors: [e.message]
162
+ )
163
+ end
164
+ end
165
+
166
+ private
167
+
168
+ # Perform move or copy operation
169
+ # @param operation [Symbol] :move or :copy
170
+ # @param items [Array<String>] Item names
171
+ # @param source_directory [String] Source directory path
172
+ # @param destination [String] Destination directory path
173
+ # @return [OperationResult]
174
+ def perform_operation(operation, items, source_directory, destination)
175
+ success_count = 0
176
+ error_messages = []
177
+
178
+ items.each do |item_name|
179
+ source_path = File.join(source_directory, item_name)
180
+ dest_path = File.join(destination, item_name)
181
+
182
+ begin
183
+ if File.exist?(dest_path)
184
+ error_messages << "#{item_name}: Already exists in destination"
185
+ next
186
+ end
187
+
188
+ case operation
189
+ when :move
190
+ FileUtils.mv(source_path, dest_path)
191
+ when :copy
192
+ if File.directory?(source_path)
193
+ FileUtils.cp_r(source_path, dest_path)
194
+ else
195
+ FileUtils.cp(source_path, dest_path)
196
+ end
197
+ end
198
+
199
+ success_count += 1
200
+ rescue StandardError => e
201
+ operation_name = operation == :move ? 'move' : 'copy'
202
+ error_messages << "#{item_name}: Failed to #{operation_name} (#{e.message})"
203
+ end
204
+ end
205
+
206
+ operation_name = operation == :move ? 'Moved' : 'Copied'
207
+ message = "#{operation_name} #{success_count} item(s)"
208
+ message += " (#{error_messages.length} failed)" unless error_messages.empty?
209
+
210
+ OperationResult.new(
211
+ success: error_messages.empty?,
212
+ message: message,
213
+ count: success_count,
214
+ errors: error_messages
215
+ )
216
+ end
217
+
218
+ # Build delete operation message
219
+ # @param success_count [Integer] Number of successfully deleted items
220
+ # @param total_count [Integer] Total number of items
221
+ # @return [String]
222
+ def build_delete_message(success_count, total_count)
223
+ if success_count == total_count
224
+ "Deleted #{success_count} item(s)"
225
+ else
226
+ failed_count = total_count - success_count
227
+ "#{success_count} deleted, #{failed_count} failed"
228
+ end
229
+ end
230
+ end
231
+ end
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rufio
4
+ class FilePreview
5
+ BINARY_THRESHOLD = 0.3 # treat as binary if 30% or more binary characters
6
+ DEFAULT_MAX_LINES = 50
7
+ MAX_LINE_LENGTH = 500
8
+
9
+ # Binary detection constants
10
+ BINARY_SAMPLE_SIZE = 512
11
+ PRINTABLE_CHAR_THRESHOLD = 32
12
+ CONTROL_CHAR_TAB = 9
13
+ CONTROL_CHAR_NEWLINE = 10
14
+ CONTROL_CHAR_CARRIAGE_RETURN = 13
15
+
16
+ def initialize
17
+ # future: hold syntax highlight settings etc.
18
+ end
19
+
20
+ def preview_file(file_path, max_lines: DEFAULT_MAX_LINES)
21
+ return error_response(ConfigLoader.message('file.not_found')) unless File.exist?(file_path)
22
+ return error_response(ConfigLoader.message('file.not_readable')) unless File.readable?(file_path)
23
+
24
+ file_size = File.size(file_path)
25
+ return empty_response if file_size == 0
26
+
27
+ begin
28
+ # binary file detection
29
+ sample = File.binread(file_path, [file_size, BINARY_SAMPLE_SIZE].min)
30
+ return binary_response(file_path) if binary_file?(sample)
31
+
32
+ # process as text file
33
+ lines = read_text_file(file_path, max_lines)
34
+ file_type = determine_file_type(file_path)
35
+
36
+ {
37
+ type: file_type[:type],
38
+ language: file_type[:language],
39
+ lines: lines[:content],
40
+ truncated: lines[:truncated],
41
+ size: file_size,
42
+ modified: File.mtime(file_path),
43
+ encoding: lines[:encoding]
44
+ }
45
+ rescue => e
46
+ error_response("#{ConfigLoader.message('file.read_error')}: #{e.message}")
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def binary_file?(sample)
53
+ return false if sample.empty?
54
+
55
+ allowed_control_chars = [CONTROL_CHAR_TAB, CONTROL_CHAR_NEWLINE, CONTROL_CHAR_CARRIAGE_RETURN]
56
+ binary_chars = sample.bytes.count { |byte| byte < PRINTABLE_CHAR_THRESHOLD && !allowed_control_chars.include?(byte) }
57
+ (binary_chars.to_f / sample.bytes.length) > BINARY_THRESHOLD
58
+ end
59
+
60
+ def read_text_file(file_path, max_lines)
61
+ lines = []
62
+ truncated = false
63
+ encoding = "UTF-8"
64
+
65
+ File.open(file_path, "r:UTF-8") do |file|
66
+ file.each_line.with_index do |line, index|
67
+ break if index >= max_lines
68
+
69
+ # truncate too long lines
70
+ if line.length > MAX_LINE_LENGTH
71
+ line = line[0...MAX_LINE_LENGTH] + "..."
72
+ end
73
+
74
+ lines << line.chomp
75
+ end
76
+
77
+ # check if there are more lines to read
78
+ truncated = !file.eof?
79
+ end
80
+
81
+ {
82
+ content: lines,
83
+ truncated: truncated,
84
+ encoding: encoding
85
+ }
86
+ rescue Encoding::InvalidByteSequenceError
87
+ # try Shift_JIS if UTF-8 fails
88
+ begin
89
+ lines = []
90
+ File.open(file_path, "r:Shift_JIS:UTF-8") do |file|
91
+ file.each_line.with_index do |line, index|
92
+ break if index >= max_lines
93
+ lines << line.chomp
94
+ end
95
+ truncated = !file.eof?
96
+ end
97
+ {
98
+ content: lines,
99
+ truncated: truncated,
100
+ encoding: "Shift_JIS"
101
+ }
102
+ rescue
103
+ {
104
+ content: ["(#{ConfigLoader.message('file.encoding_error')})"],
105
+ truncated: false,
106
+ encoding: "unknown"
107
+ }
108
+ end
109
+ end
110
+
111
+ def determine_file_type(file_path)
112
+ extension = File.extname(file_path).downcase
113
+
114
+ case extension
115
+ when ".rb"
116
+ { type: "code", language: "ruby" }
117
+ when ".py"
118
+ { type: "code", language: "python" }
119
+ when ".js", ".mjs"
120
+ { type: "code", language: "javascript" }
121
+ when ".ts"
122
+ { type: "code", language: "typescript" }
123
+ when ".html", ".htm"
124
+ { type: "code", language: "html" }
125
+ when ".css"
126
+ { type: "code", language: "css" }
127
+ when ".json"
128
+ { type: "code", language: "json" }
129
+ when ".yml", ".yaml"
130
+ { type: "code", language: "yaml" }
131
+ when ".md", ".markdown"
132
+ { type: "code", language: "markdown" }
133
+ when ".txt", ".log"
134
+ { type: "text", language: nil }
135
+ when ".zip", ".tar", ".gz", ".bz2", ".xz", ".7z"
136
+ { type: "archive", language: nil }
137
+ when ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".svg"
138
+ { type: "image", language: nil }
139
+ when ".pdf"
140
+ { type: "document", language: nil }
141
+ when ".exe", ".dmg", ".deb", ".rpm"
142
+ { type: "executable", language: nil }
143
+ else
144
+ { type: "text", language: nil }
145
+ end
146
+ end
147
+
148
+ def empty_response
149
+ {
150
+ type: "empty",
151
+ lines: [],
152
+ size: 0,
153
+ modified: File.mtime(""),
154
+ encoding: "UTF-8"
155
+ }
156
+ rescue
157
+ {
158
+ type: "empty",
159
+ lines: [],
160
+ size: 0,
161
+ modified: Time.now,
162
+ encoding: "UTF-8"
163
+ }
164
+ end
165
+
166
+ def binary_response(file_path = nil)
167
+ file_size = file_path ? File.size(file_path) : 0
168
+ modified_time = file_path ? File.mtime(file_path) : Time.now
169
+
170
+ {
171
+ type: "binary",
172
+ message: "#{ConfigLoader.message('file.binary_file')} - #{ConfigLoader.message('file.cannot_preview')}",
173
+ lines: ["(#{ConfigLoader.message('file.binary_file')})"],
174
+ size: file_size,
175
+ modified: modified_time,
176
+ encoding: "binary"
177
+ }
178
+ rescue => e
179
+ {
180
+ type: "binary",
181
+ message: "#{ConfigLoader.message('file.binary_file')} - #{ConfigLoader.message('file.cannot_preview')}",
182
+ lines: ["(#{ConfigLoader.message('file.binary_file')})"],
183
+ size: 0,
184
+ modified: Time.now,
185
+ encoding: "binary"
186
+ }
187
+ end
188
+
189
+ def error_response(message)
190
+ {
191
+ type: "error",
192
+ message: message,
193
+ lines: ["#{ConfigLoader.message('file.error_prefix')}: #{message}"],
194
+ size: 0,
195
+ modified: Time.now,
196
+ encoding: "UTF-8"
197
+ }
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rufio
4
+ # Manages filtering of directory entries
5
+ class FilterManager
6
+ attr_reader :filter_query, :filter_mode
7
+
8
+ def initialize
9
+ @filter_mode = false
10
+ @filter_query = ''
11
+ @original_entries = []
12
+ @filtered_entries = []
13
+ end
14
+
15
+ # Start filter mode with the given entries
16
+ # @param entries [Array<Hash>] Directory entries to filter
17
+ def start_filter_mode(entries)
18
+ @filter_mode = true
19
+ @filter_query = ''
20
+ @original_entries = entries.dup
21
+ @filtered_entries = @original_entries.dup
22
+ true
23
+ end
24
+
25
+ # Handle filter input character
26
+ # @param key [String] Input key
27
+ # @return [Symbol] :exit_clear, :exit_keep, :continue, or :backspace_exit
28
+ def handle_filter_input(key)
29
+ case key
30
+ when "\e" # ESC - clear filter and exit
31
+ :exit_clear
32
+ when "\r", "\n" # Enter - keep filter and exit
33
+ :exit_keep
34
+ when "\u007f", "\b" # Backspace
35
+ if @filter_query.length > 0
36
+ @filter_query = @filter_query[0...-1]
37
+ apply_filter
38
+ :continue
39
+ else
40
+ :backspace_exit
41
+ end
42
+ else
43
+ # Printable characters (alphanumeric, symbols, Japanese, etc.)
44
+ if key.length == 1 && key.ord >= 32 && key.ord < 127 # ASCII printable
45
+ @filter_query += key
46
+ apply_filter
47
+ :continue
48
+ elsif key.bytesize > 1 # Multi-byte characters (Japanese, etc.)
49
+ @filter_query += key
50
+ apply_filter
51
+ :continue
52
+ else
53
+ # Ignore other keys (Ctrl+c, etc.)
54
+ :continue
55
+ end
56
+ end
57
+ end
58
+
59
+ # Apply filter to entries
60
+ # @return [Array<Hash>] Filtered entries
61
+ def apply_filter
62
+ if @filter_query.empty?
63
+ @filtered_entries = @original_entries.dup
64
+ else
65
+ query_downcase = @filter_query.downcase
66
+ @filtered_entries = @original_entries.select do |entry|
67
+ entry[:name].downcase.include?(query_downcase)
68
+ end
69
+ end
70
+ @filtered_entries
71
+ end
72
+
73
+ # Clear filter mode
74
+ def clear_filter
75
+ @filter_mode = false
76
+ @filter_query = ''
77
+ @filtered_entries = []
78
+ @original_entries = []
79
+ end
80
+
81
+ # Exit filter mode while keeping the filter
82
+ def exit_filter_mode_keep_filter
83
+ @filter_mode = false
84
+ # Keep @filter_query and @filtered_entries
85
+ end
86
+
87
+ # Check if filter is active
88
+ # @return [Boolean]
89
+ def filter_active?
90
+ @filter_mode || !@filter_query.empty?
91
+ end
92
+
93
+ # Get filtered entries
94
+ # @return [Array<Hash>]
95
+ def filtered_entries
96
+ @filtered_entries
97
+ end
98
+
99
+ # Update original entries (e.g., after directory refresh)
100
+ # @param entries [Array<Hash>] New entries
101
+ def update_entries(entries)
102
+ @original_entries = entries.dup
103
+ apply_filter if filter_active?
104
+ end
105
+
106
+ # Restart filter mode with existing query
107
+ # @param entries [Array<Hash>] Directory entries
108
+ def restart_filter_mode(entries)
109
+ @filter_mode = true
110
+ @original_entries = entries.dup if @original_entries.empty?
111
+ apply_filter
112
+ end
113
+ end
114
+ end