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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +188 -0
- data/CHANGELOG_v0.4.0.md +146 -0
- data/CHANGELOG_v0.5.0.md +26 -0
- data/CHANGELOG_v0.6.0.md +182 -0
- data/CHANGELOG_v0.7.0.md +280 -0
- data/CHANGELOG_v0.8.0.md +267 -0
- data/CHANGELOG_v0.9.0.md +279 -0
- data/README.md +631 -0
- data/README_EN.md +561 -0
- data/Rakefile +156 -0
- data/bin/rufio +34 -0
- data/config_example.rb +88 -0
- data/docs/PLUGIN_GUIDE.md +431 -0
- data/docs/plugin_example.rb +119 -0
- data/lib/rufio/application.rb +32 -0
- data/lib/rufio/bookmark.rb +115 -0
- data/lib/rufio/bookmark_manager.rb +173 -0
- data/lib/rufio/color_helper.rb +150 -0
- data/lib/rufio/command_mode.rb +72 -0
- data/lib/rufio/command_mode_ui.rb +168 -0
- data/lib/rufio/config.rb +199 -0
- data/lib/rufio/config_loader.rb +110 -0
- data/lib/rufio/dialog_renderer.rb +127 -0
- data/lib/rufio/directory_listing.rb +113 -0
- data/lib/rufio/file_opener.rb +140 -0
- data/lib/rufio/file_operations.rb +231 -0
- data/lib/rufio/file_preview.rb +200 -0
- data/lib/rufio/filter_manager.rb +114 -0
- data/lib/rufio/health_checker.rb +246 -0
- data/lib/rufio/keybind_handler.rb +828 -0
- data/lib/rufio/logger.rb +103 -0
- data/lib/rufio/plugin.rb +89 -0
- data/lib/rufio/plugin_config.rb +59 -0
- data/lib/rufio/plugin_manager.rb +84 -0
- data/lib/rufio/plugins/file_operations.rb +44 -0
- data/lib/rufio/selection_manager.rb +79 -0
- data/lib/rufio/terminal_ui.rb +630 -0
- data/lib/rufio/text_utils.rb +108 -0
- data/lib/rufio/version.rb +5 -0
- data/lib/rufio/zoxide_integration.rb +188 -0
- data/lib/rufio.rb +33 -0
- data/publish_gem.zsh +131 -0
- data/rufio.gemspec +40 -0
- data/test_delete/test1.txt +1 -0
- data/test_delete/test2.txt +1 -0
- 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
|