rufio 1.0.4 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +46 -0
- data/README.md +105 -2
- data/README_ja.md +103 -2
- data/Rakefile +14 -1
- data/bin/rufio +31 -2
- data/docs/fast_zip_design_ja.md +167 -0
- data/examples/config.rb +8 -0
- data/info/help.md +2 -0
- data/info/keybindings.md +3 -1
- data/info/welcome.md +1 -1
- data/lib/rufio/application.rb +12 -1
- data/lib/rufio/archive_controller.rb +211 -0
- data/lib/rufio/background_command_executor.rb +9 -0
- data/lib/rufio/background_status.rb +49 -0
- data/lib/rufio/bookmark_controller.rb +38 -121
- data/lib/rufio/bookmark_manager.rb +77 -331
- data/lib/rufio/command_completion.rb +6 -1
- data/lib/rufio/command_history.rb +5 -2
- data/lib/rufio/command_mode.rb +40 -134
- data/lib/rufio/command_mode_ui.rb +4 -42
- data/lib/rufio/config.rb +89 -137
- data/lib/rufio/config_loader.rb +21 -13
- data/lib/rufio/dialog_renderer.rb +64 -152
- data/lib/rufio/dialog_support.rb +69 -0
- data/lib/rufio/document_text_extractor.rb +167 -0
- data/lib/rufio/file_operation_controller.rb +49 -222
- data/lib/rufio/file_preview.rb +46 -2
- data/lib/rufio/global_search.rb +373 -0
- data/lib/rufio/help_menu.rb +27 -2
- data/lib/rufio/interpreter_resolver.rb +6 -11
- data/lib/rufio/job_manager.rb +2 -5
- data/lib/rufio/key_input.rb +77 -0
- data/lib/rufio/keybind_handler.rb +120 -112
- data/lib/rufio/local_script_scanner.rb +6 -18
- data/lib/rufio/native/aarch64-linux-gnu/librufio_zig.so +0 -0
- data/lib/rufio/native/aarch64-linux-musl/librufio_zig.so +0 -0
- data/lib/rufio/native/aarch64-macos/librufio_zig.dylib +0 -0
- data/lib/rufio/native/aarch64-windows/rufio_zig.dll +0 -0
- data/lib/rufio/native/x86_64-linux-gnu/librufio_zig.so +0 -0
- data/lib/rufio/native/x86_64-linux-musl/librufio_zig.so +0 -0
- data/lib/rufio/native/x86_64-macos/librufio_zig.dylib +0 -0
- data/lib/rufio/native/x86_64-windows/rufio_zig.dll +0 -0
- data/lib/rufio/native_content_search.rb +153 -0
- data/lib/rufio/native_scanner.rb +13 -68
- data/lib/rufio/native_scanner_zig.rb +106 -56
- data/lib/rufio/native_zip.rb +121 -0
- data/lib/rufio/navigation_controller.rb +18 -20
- data/lib/rufio/screen.rb +2 -12
- data/lib/rufio/script_executor.rb +6 -73
- data/lib/rufio/script_file.rb +44 -0
- data/lib/rufio/script_path_manager.rb +9 -43
- data/lib/rufio/script_runner.rb +13 -50
- data/lib/rufio/search_controller.rb +1 -1
- data/lib/rufio/shell_command_completion.rb +1 -5
- data/lib/rufio/shell_init.rb +90 -0
- data/lib/rufio/tab_mode_manager.rb +5 -1
- data/lib/rufio/task_status.rb +5 -1
- data/lib/rufio/terminal_ui.rb +116 -293
- data/lib/rufio/text_utils.rb +26 -3
- data/lib/rufio/ui_renderer.rb +68 -41
- data/lib/rufio/version.rb +1 -1
- data/lib/rufio/zip_archive.rb +314 -0
- data/lib/rufio/zip_extractor.rb +346 -0
- data/lib/rufio/zip_writer.rb +268 -0
- data/lib/rufio/zoxide_integration.rb +5 -43
- data/lib/rufio.rb +5 -0
- data/lib_zig/rufio_native/Makefile +54 -17
- data/lib_zig/rufio_native/bench.sh +36 -0
- data/lib_zig/rufio_native/build.zig +32 -35
- data/lib_zig/rufio_native/src/cli.zig +99 -0
- data/lib_zig/rufio_native/src/main.zig +187 -83
- data/lib_zig/rufio_native/src/os.zig +889 -0
- data/lib_zig/rufio_native/src/search.zig +919 -0
- data/lib_zig/rufio_native/src/search_test.zig +88 -0
- data/lib_zig/rufio_native/src/zip.zig +482 -0
- metadata +126 -3
- data/lib/rufio/native/rufio_zig.bundle +0 -0
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'thread'
|
|
4
|
+
require_relative 'text_utils'
|
|
5
|
+
require_relative 'config_loader'
|
|
6
|
+
require_relative 'document_text_extractor'
|
|
7
|
+
require_relative 'native_content_search'
|
|
8
|
+
|
|
9
|
+
module Rufio
|
|
10
|
+
# ブックマーク配下のファイル名・テキスト内容をバックグラウンドで横断検索する。
|
|
11
|
+
class GlobalSearch
|
|
12
|
+
SPINNER_FRAMES = ['|', '/', '-', '\\'].freeze
|
|
13
|
+
SPINNER_INTERVAL = 0.12
|
|
14
|
+
# 検索語に一致した部分の表示(黒の太字・黄色背景)
|
|
15
|
+
MATCH_FG = "\e[30m\e[1m"
|
|
16
|
+
MATCH_BG = "\e[43m"
|
|
17
|
+
|
|
18
|
+
attr_reader :query, :results, :errors, :selected_index, :search_type, :skipped_files
|
|
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
|
+
|
|
30
|
+
def initialize
|
|
31
|
+
@active = false
|
|
32
|
+
@scanning = false
|
|
33
|
+
@worker = nil
|
|
34
|
+
@document_extractor = DocumentTextExtractor.new
|
|
35
|
+
@document_cache = DocumentTextCache.new(extractor: @document_extractor)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def active?
|
|
39
|
+
@active
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def scanning?
|
|
43
|
+
@scanning
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def open(bookmarks)
|
|
47
|
+
close
|
|
48
|
+
@active = true
|
|
49
|
+
@query = ''
|
|
50
|
+
@search_type = :filename
|
|
51
|
+
@excluded_extensions = ConfigLoader.global_search_excluded_extensions
|
|
52
|
+
@bookmark_count = bookmarks.length
|
|
53
|
+
@roots = bookmarks.map { |bookmark| [bookmark[:name].to_s, bookmark[:path].to_s] }
|
|
54
|
+
start_scan
|
|
55
|
+
true
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def close
|
|
59
|
+
@active = false
|
|
60
|
+
@scanning = false
|
|
61
|
+
@worker&.kill
|
|
62
|
+
@worker&.join
|
|
63
|
+
@worker = nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# 1フレーム当たりの処理量を制限し、検索中も入力を受け付ける。
|
|
67
|
+
def poll
|
|
68
|
+
return false unless active?
|
|
69
|
+
|
|
70
|
+
changed = false
|
|
71
|
+
256.times do
|
|
72
|
+
event = begin
|
|
73
|
+
@events.pop(true)
|
|
74
|
+
rescue ThreadError
|
|
75
|
+
break
|
|
76
|
+
end
|
|
77
|
+
changed = true
|
|
78
|
+
type, value = event
|
|
79
|
+
case type
|
|
80
|
+
when :entry
|
|
81
|
+
@entries << value
|
|
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)
|
|
87
|
+
when :error then @errors << value
|
|
88
|
+
when :skipped then @skipped_files += value || 1
|
|
89
|
+
when :done then @scanning = false
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
if scanning?
|
|
93
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
94
|
+
if now - @last_spinner_update >= SPINNER_INTERVAL
|
|
95
|
+
@spinner_index = (@spinner_index + 1) % SPINNER_FRAMES.length
|
|
96
|
+
@last_spinner_update = now
|
|
97
|
+
changed = true
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
changed
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def handle_key(key)
|
|
104
|
+
case key
|
|
105
|
+
when "\e" then close
|
|
106
|
+
when "\t"
|
|
107
|
+
@search_type = @search_type == :filename ? :content : :filename
|
|
108
|
+
start_scan
|
|
109
|
+
when "\r", "\n" then return @results[@selected_index]
|
|
110
|
+
when :down then @selected_index = [@selected_index + 1, [@results.length - 1, 0].max].min
|
|
111
|
+
when :up then @selected_index = [@selected_index - 1, 0].max
|
|
112
|
+
when "\x0e" then handle_key(:down) # Ctrl+N
|
|
113
|
+
when "\x10" then handle_key(:up) # Ctrl+P
|
|
114
|
+
when "\x15" then update_query('') # Ctrl+U
|
|
115
|
+
when "\u007f", "\b" then update_query(@query[0...-1])
|
|
116
|
+
else
|
|
117
|
+
update_query(@query + key) if key.is_a?(String) && !key.empty? && key.each_codepoint.all? { |c| c >= 32 && c != 127 }
|
|
118
|
+
end
|
|
119
|
+
nil
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def draw(screen)
|
|
123
|
+
screen.clear
|
|
124
|
+
width = screen.width
|
|
125
|
+
type = @search_type == :content ? 'Content' : 'File names'
|
|
126
|
+
write_line(screen, 0, "G mode - #{type} | Tab: Switch name/content search", width, "\e[1;36m")
|
|
127
|
+
write_line(screen, 1, "Search: #{@query}_", width)
|
|
128
|
+
state = if scanning?
|
|
129
|
+
"[#{SPINNER_FRAMES[@spinner_index]}] Searching..."
|
|
130
|
+
elsif @search_type == :content && @query.empty?
|
|
131
|
+
'Ready'
|
|
132
|
+
else
|
|
133
|
+
'Search complete'
|
|
134
|
+
end
|
|
135
|
+
color = scanning? ? "\e[1;33m" : "\e[32m"
|
|
136
|
+
write_line(screen, 2, "#{state} | #{@bookmark_count} bookmarks | #{@results.length} matches | #{@errors.length} errors | #{@skipped_files} skipped", width, color)
|
|
137
|
+
row_height = @search_type == :content ? 2 : 1
|
|
138
|
+
capacity = [screen.height - 6, 0].max / row_height
|
|
139
|
+
first = [@selected_index - capacity + 1, 0].max
|
|
140
|
+
@results.slice(first, capacity).to_a.each_with_index do |entry, index|
|
|
141
|
+
selected = first + index == @selected_index
|
|
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
|
|
151
|
+
y = index * row_height + 3
|
|
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]
|
|
156
|
+
end
|
|
157
|
+
if @results.empty?
|
|
158
|
+
message = if @bookmark_count.zero?
|
|
159
|
+
'No bookmarks. Add directories with b in Files mode.'
|
|
160
|
+
elsif scanning?
|
|
161
|
+
'Searching bookmarked directories...'
|
|
162
|
+
elsif @search_type == :content && @query.empty?
|
|
163
|
+
'Type a string to search inside text files.'
|
|
164
|
+
elsif !@errors.empty? && @entries.empty?
|
|
165
|
+
'No readable files in bookmarked directories.'
|
|
166
|
+
else
|
|
167
|
+
'No matching files. Edit your search to try again.'
|
|
168
|
+
end
|
|
169
|
+
write_line(screen, 3, message, width)
|
|
170
|
+
end
|
|
171
|
+
write_line(screen, screen.height - 3, @results[@selected_index]&.[](:path) || @errors.first.to_s, width, "\e[90m")
|
|
172
|
+
write_line(screen, screen.height - 2, 'ESC: Back | Enter: Go to file | Up/Down: Select | Type: Search', width)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
private
|
|
176
|
+
|
|
177
|
+
def start_scan
|
|
178
|
+
@worker&.kill
|
|
179
|
+
@worker&.join
|
|
180
|
+
@worker = nil
|
|
181
|
+
@entries = []
|
|
182
|
+
@results = []
|
|
183
|
+
@errors = []
|
|
184
|
+
@skipped_files = 0
|
|
185
|
+
@selected_index = 0
|
|
186
|
+
@events = SizedQueue.new(256)
|
|
187
|
+
@spinner_index = 0
|
|
188
|
+
@last_spinner_update = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
189
|
+
@scanning = !(@search_type == :content && @query.empty?)
|
|
190
|
+
return unless @scanning
|
|
191
|
+
|
|
192
|
+
roots, events, type, query = @roots, @events, @search_type, @query.downcase
|
|
193
|
+
native_cache = self.class.native_search? ? (@native_cache ||= NativeContentSearch::Cache.new) : nil
|
|
194
|
+
@worker = Thread.new do
|
|
195
|
+
begin
|
|
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
|
|
201
|
+
rescue StandardError => e
|
|
202
|
+
events << [:error, e.message]
|
|
203
|
+
end
|
|
204
|
+
events << [:done, nil]
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def update_query(query)
|
|
209
|
+
@query = query
|
|
210
|
+
@selected_index = 0
|
|
211
|
+
@search_type == :content ? start_scan : filter_results
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def filter_results
|
|
215
|
+
query = @query.downcase
|
|
216
|
+
@results = @entries.select { |entry| entry[:relative_path].scrub.downcase.include?(query) }
|
|
217
|
+
@selected_index = [[@selected_index, @results.length - 1].min, 0].max
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def matches_query?(entry)
|
|
221
|
+
entry[:relative_path].scrub.downcase.include?(@query.downcase)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def scan_roots(roots, events, type, query)
|
|
225
|
+
visited_dirs = {}
|
|
226
|
+
roots.each do |name, root|
|
|
227
|
+
begin
|
|
228
|
+
root = File.expand_path(root)
|
|
229
|
+
raise Errno::ENOTDIR, root unless File.directory?(root)
|
|
230
|
+
|
|
231
|
+
pending = [root]
|
|
232
|
+
until pending.empty?
|
|
233
|
+
directory = pending.pop
|
|
234
|
+
canonical = File.realpath(directory)
|
|
235
|
+
next if visited_dirs[canonical]
|
|
236
|
+
|
|
237
|
+
visited_dirs[canonical] = true
|
|
238
|
+
begin
|
|
239
|
+
Dir.each_child(directory) do |child|
|
|
240
|
+
path = File.join(directory, child)
|
|
241
|
+
# ディレクトリシンボリックリンクは追わず、循環・範囲外の走査を防ぐ。
|
|
242
|
+
if File.directory?(path)
|
|
243
|
+
pending << path unless File.symlink?(path)
|
|
244
|
+
elsif File.file?(path)
|
|
245
|
+
if child.scrub.downcase.end_with?(*@excluded_extensions)
|
|
246
|
+
events << [:skipped, nil]
|
|
247
|
+
next
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
entry = { path: path, bookmark: name, relative_path: path.delete_prefix(root + File::SEPARATOR) }
|
|
251
|
+
if type == :content
|
|
252
|
+
scan_content(entry, query, events)
|
|
253
|
+
else
|
|
254
|
+
events << [:entry, entry]
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
rescue SystemCallError, IOError => e
|
|
259
|
+
events << [:error, "#{directory}: #{e.message}"]
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
rescue SystemCallError, IOError => e
|
|
263
|
+
events << [:error, "#{name}: #{e.message}"]
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
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
|
+
|
|
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
|
+
|
|
320
|
+
File.open(entry[:path], 'rb') do |file|
|
|
321
|
+
sample = file.read(8192).to_s
|
|
322
|
+
if sample.include?("\0")
|
|
323
|
+
events << [:skipped, nil]
|
|
324
|
+
return
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
file.rewind
|
|
328
|
+
file.each_line.with_index(1) do |raw_line, number|
|
|
329
|
+
line = raw_line.force_encoding(Encoding::UTF_8).scrub
|
|
330
|
+
match_index = line.downcase.index(query)
|
|
331
|
+
next unless match_index
|
|
332
|
+
|
|
333
|
+
snippet_start = [match_index - 30, 0].max
|
|
334
|
+
snippet = line[snippet_start, 200].strip
|
|
335
|
+
events << [:entry, entry.merge(line_number: number, match_text: snippet)]
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
rescue SystemCallError, IOError, DocumentTextExtractor::ExtractionError => e
|
|
339
|
+
events << [:error, "#{entry[:path]}: #{e.message}"]
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def write_line(screen, y, text, width, color = nil, highlight_from: nil)
|
|
343
|
+
safe_text = text.scrub.gsub(/[[:cntrl:]]/, ' ')
|
|
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
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
end
|
data/lib/rufio/help_menu.rb
CHANGED
|
@@ -62,21 +62,46 @@ 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',
|
|
68
69
|
"#{key(:filter)}: Filter | Enter: Apply | ESC: Clear",
|
|
69
70
|
"#{key(:fzf_search)}: Filename search (fzf)",
|
|
70
71
|
"#{key(:rga_search)}: Content search (rga)",
|
|
72
|
+
"#{key(:global_search)}: Search file names or contents across all bookmarks",
|
|
71
73
|
"#{key(:add_bookmark)}/#{key(:bookmark_menu)}: Add / manage bookmarks",
|
|
72
74
|
'Tab / Shift+Tab: Next / previous bookmark',
|
|
73
75
|
"1-9: Jump to bookmark | #{key(:start_dir)}: Start directory",
|
|
74
|
-
"#{key(:zoxide)}: Directory history (zoxide)",
|
|
75
|
-
"#{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)"
|
|
76
79
|
]
|
|
77
80
|
when 'Modes'
|
|
78
81
|
[
|
|
79
82
|
'Files', 'Browse directories, select files and view previews.',
|
|
83
|
+
'', "[#{key(:config_mode)}] Config",
|
|
84
|
+
'Open ~/.config/rufio/ to edit settings and scripts.',
|
|
85
|
+
'The config directory is created if it does not exist.',
|
|
86
|
+
'Navigation, previews and file operations work as in Files.',
|
|
87
|
+
"#{key(:navigate_parent)}: Parent directory; leaving config returns to Files.",
|
|
88
|
+
'', "[#{key(:global_search)}] Global search",
|
|
89
|
+
'Search file names or text inside every bookmarked directory.',
|
|
90
|
+
'Tab: Switch File names / Content (literal, case insensitive)',
|
|
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.',
|
|
96
|
+
'Yellow Searching... + spinner: Search is running.',
|
|
97
|
+
'Green Search complete: All bookmarked directories scanned.',
|
|
98
|
+
'Exclude extensions in ~/.config/rufio/config.rb:',
|
|
99
|
+
'GLOBAL_SEARCH_EXCLUDED_EXTENSIONS = %w[.zip .log]',
|
|
100
|
+
'Applies to names and contents. Restart Rufio after editing.',
|
|
101
|
+
'Extensions ignore case; leading dots are optional; [] excludes none.',
|
|
102
|
+
'Type to filter | Up/Down or Ctrl+N/P: Select',
|
|
103
|
+
'Enter: Go to the selected file in Files mode',
|
|
104
|
+
'ESC: Return to the previous mode | Ctrl+U: Clear query',
|
|
80
105
|
'', "[#{key(:log_viewer)}] Logs",
|
|
81
106
|
'Browse command logs. Enter opens a preview.',
|
|
82
107
|
'ESC: Leave preview; ESC again: Return to Files.',
|
|
@@ -1,20 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'script_file'
|
|
4
|
+
|
|
3
5
|
module Rufio
|
|
4
6
|
# 拡張子からインタープリタを解決するクラス
|
|
5
7
|
class InterpreterResolver
|
|
6
8
|
# デフォルトの拡張子とインタープリタのマッピング
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
".
|
|
10
|
-
|
|
11
|
-
".js" => "node",
|
|
12
|
-
".pl" => "perl",
|
|
13
|
-
".lua" => "lua",
|
|
14
|
-
".ts" => "ts-node",
|
|
15
|
-
".php" => "php",
|
|
16
|
-
".ps1" => nil # プラットフォーム依存
|
|
17
|
-
}.freeze
|
|
9
|
+
# DSLでは .sh にインタープリタを指定し、WindowsのPowerShellを別途解決する。
|
|
10
|
+
DEFAULT_EXTENSIONS = ScriptFile::INTERPRETERS.merge(
|
|
11
|
+
".sh" => "bash", ".lua" => "lua", ".php" => "php", ".ps1" => nil
|
|
12
|
+
).freeze
|
|
18
13
|
|
|
19
14
|
class << self
|
|
20
15
|
# 拡張子からインタープリタを解決する
|
data/lib/rufio/job_manager.rb
CHANGED
|
@@ -23,12 +23,9 @@ module Rufio
|
|
|
23
23
|
job = TaskStatus.new(
|
|
24
24
|
id: @next_id,
|
|
25
25
|
name: name,
|
|
26
|
-
path: path
|
|
26
|
+
path: path,
|
|
27
|
+
command: command
|
|
27
28
|
)
|
|
28
|
-
# commandを保存(TaskStatusに追加)
|
|
29
|
-
job.instance_variable_set(:@command, command)
|
|
30
|
-
job.define_singleton_method(:command) { @command }
|
|
31
|
-
|
|
32
29
|
@jobs << job
|
|
33
30
|
@next_id += 1
|
|
34
31
|
job
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'io/console'
|
|
4
|
+
|
|
5
|
+
module Rufio
|
|
6
|
+
# ダイアログなどのブロッキングなキー入力の読み取り口。
|
|
7
|
+
# Windows ではメインループ用の入力スレッドが常に STDIN.getch で待機しているため、
|
|
8
|
+
# ダイアログが STDIN.getch を直接呼ぶと 1 キーずつ入力を奪い合い、2 回押さないと入力できない。
|
|
9
|
+
# 入力キューが接続されている間はキューから読み、それ以外は STDIN.getch を使う。
|
|
10
|
+
module KeyInput
|
|
11
|
+
# Ruby 3.2+ は Queue#pop(timeout:) で正確に待機する(sleep ポーリングは
|
|
12
|
+
# Windows のタイマー分解能 約15ms により遅延する)。それ以前はポーリング。
|
|
13
|
+
QUEUE_POP_TIMEOUT_SUPPORTED = Queue.instance_method(:pop).parameters.any? { |_, n| n == :timeout }
|
|
14
|
+
MULTIBYTE_TIMEOUT = 0.005
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
attr_reader :queue
|
|
18
|
+
|
|
19
|
+
def attach(queue)
|
|
20
|
+
@queue = queue
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def detach
|
|
24
|
+
@queue = nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# 1 文字(マルチバイト対応)を読むまでブロックする
|
|
28
|
+
def getch
|
|
29
|
+
queue = @queue
|
|
30
|
+
return STDIN.getch unless queue
|
|
31
|
+
|
|
32
|
+
loop do
|
|
33
|
+
char = build_char(queue.pop, queue)
|
|
34
|
+
return char if char
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# キューからタイムアウト付きで Integer バイトを取り出す
|
|
39
|
+
def pop_with_timeout(queue, timeout_sec)
|
|
40
|
+
return queue.pop(timeout: timeout_sec) if QUEUE_POP_TIMEOUT_SUPPORTED
|
|
41
|
+
|
|
42
|
+
deadline = Time.now + timeout_sec
|
|
43
|
+
loop do
|
|
44
|
+
begin
|
|
45
|
+
return queue.pop(true)
|
|
46
|
+
rescue ThreadError
|
|
47
|
+
return nil if Time.now >= deadline
|
|
48
|
+
|
|
49
|
+
sleep 0.001
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# first_byte_int に続く UTF-8 の後続バイトをキューから読み、1 文字に組み立てる
|
|
55
|
+
def build_char(first_byte_int, queue)
|
|
56
|
+
remaining = case first_byte_int
|
|
57
|
+
when 0x00..0x7F then 0
|
|
58
|
+
when 0xC0..0xDF then 1
|
|
59
|
+
when 0xE0..0xEF then 2
|
|
60
|
+
when 0xF0..0xF7 then 3
|
|
61
|
+
else 0
|
|
62
|
+
end
|
|
63
|
+
return first_byte_int.chr(Encoding::UTF_8) if remaining == 0
|
|
64
|
+
|
|
65
|
+
buf = first_byte_int.chr(Encoding::BINARY)
|
|
66
|
+
remaining.times do
|
|
67
|
+
b = pop_with_timeout(queue, MULTIBYTE_TIMEOUT)
|
|
68
|
+
return nil if b.nil?
|
|
69
|
+
|
|
70
|
+
buf << b.chr(Encoding::BINARY)
|
|
71
|
+
end
|
|
72
|
+
result = buf.force_encoding(Encoding::UTF_8)
|
|
73
|
+
result.valid_encoding? ? result : nil
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|