rufio 1.4.0 → 1.5.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 +13 -0
- data/README.md +2 -0
- data/README_ja.md +2 -0
- data/docs/guide.md +7 -5
- data/docs/guide_ja.md +7 -5
- data/info/help.md +4 -1
- data/info/keybindings.md +2 -0
- data/info/welcome.md +2 -0
- data/lib/rufio/config_loader.rb +2 -2
- data/lib/rufio/file_preview.rb +19 -56
- data/lib/rufio/global_search.rb +44 -5
- data/lib/rufio/help_menu.rb +3 -1
- 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 +32 -10
- data/lib/rufio/native_scanner_zig.rb +13 -0
- data/lib/rufio/version.rb +1 -1
- data/lib_zig/rufio_native/src/cli.zig +24 -10
- data/lib_zig/rufio_native/src/main.zig +16 -0
- data/lib_zig/rufio_native/src/search.zig +52 -5
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e68729d1cfbfe899d2bdb0c2626ab46958561319aa6a33f8224eb5aaae4e0ad7
|
|
4
|
+
data.tar.gz: 93bfc8f597d0c16339a168b1711abde8bc5251fb7cd8209b199c3d71f0e7068d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c5631c3ef32be2d26bd9759d32a3c7680274b49291e14054bce8184821445ce9f3209a1c99ceeffcfcde0149d458215c2d3328ce363cff8c85f6b470b5ade7f5
|
|
7
|
+
data.tar.gz: 60dfadd02062abd388eb458083685f7b36fb98b0193b5a95a94bccdb6f7a8b962e59f46cbccef12e8962ad0e35688ef522d1dbf6a89bd44c6049717d93f2e602
|
data/CHANGELOG.md
CHANGED
|
@@ -7,8 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.5.0] - 2026-11-14
|
|
11
|
+
|
|
10
12
|
### Added
|
|
11
13
|
- G mode supports `Ctrl+J` / `Ctrl+K` to select the next / previous result; Enter (CR) opens the selected result
|
|
14
|
+
- `GLOBAL_SEARCH_EXCLUDED_EXTENSIONS` accepts names starting with `/` (for example `/.git`, `/node_modules`, `/.DS_Store`) to skip a directory and everything under it, or files with exactly that name. Names ignore case, and extension entries such as `.png` keep working as before
|
|
15
|
+
- The bundled Zig library adds a file listing mode (`core_files_create`) that walks directories in parallel without opening files, rebuilt for all eight bundled platforms. The `rufio_search` benchmark CLI gains `--files` for comparison with `fd`
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
- G mode file name search walks bookmarked directories with the parallel Zig engine instead of a single Ruby thread. `RUFIO_NATIVE_SEARCH=0` or an older library without the file listing function falls back to the Ruby walk
|
|
19
|
+
|
|
20
|
+
## [1.4.1] - 2026-11-08
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
- Skip PDF body reads in file previews so large binary streams do not block file navigation
|
|
24
|
+
- Limit text preview reads to the first 64 KiB before decoding and report omitted lines correctly
|
|
12
25
|
|
|
13
26
|
## [1.4.0] - 2026-11-02
|
|
14
27
|
|
data/README.md
CHANGED
|
@@ -39,6 +39,8 @@ rufio brings finding files, running scripts, and checking results together in on
|
|
|
39
39
|
- **Bookmarks**: Quick access to frequently used directories
|
|
40
40
|
- **zoxide Integration**: Smart directory history
|
|
41
41
|
|
|
42
|
+
Text previews read at most the first 64 KiB. PDF bodies are not previewed, keeping file navigation responsive.
|
|
43
|
+
|
|
42
44
|
### Cross-platform
|
|
43
45
|
|
|
44
46
|
- **macOS**: Native support
|
data/README_ja.md
CHANGED
data/docs/guide.md
CHANGED
|
@@ -60,6 +60,8 @@ The mouse also works: click to select, and scroll the list or the preview.
|
|
|
60
60
|
|
|
61
61
|
## Preview
|
|
62
62
|
|
|
63
|
+
Text previews read at most the first 64 KiB. PDF bodies are not previewed, keeping file navigation responsive.
|
|
64
|
+
|
|
63
65
|
The right pane previews the selected file. Code is highlighted when `bat` is installed. `.zip` files show their entry list (size, method, modified time, name) without extracting them.
|
|
64
66
|
|
|
65
67
|
| Key | Action |
|
|
@@ -138,15 +140,15 @@ Content search also reads documents, without Python, Microsoft Office or convers
|
|
|
138
140
|
|
|
139
141
|
Extracted text is cached in memory (up to 64 documents, about 16 MiB) and re-extracted when a file changes. Scanned images need OCR, which is not included. Legacy `.xls` / `.ppt` and slide notes are not supported.
|
|
140
142
|
|
|
141
|
-
### Excluding Extensions
|
|
143
|
+
### Excluding Extensions and Directories
|
|
142
144
|
|
|
143
145
|
Add this to `~/.config/rufio/config.rb` and restart rufio. It applies to both file names and contents:
|
|
144
146
|
|
|
145
147
|
```ruby
|
|
146
|
-
GLOBAL_SEARCH_EXCLUDED_EXTENSIONS = %w[.png .jpg .zip .tar.gz .log].freeze
|
|
148
|
+
GLOBAL_SEARCH_EXCLUDED_EXTENSIONS = %w[.png .jpg .zip .tar.gz .log /.git /node_modules /.DS_Store].freeze
|
|
147
149
|
```
|
|
148
150
|
|
|
149
|
-
Extensions ignore case, the leading dot is optional, and the default `[]` excludes nothing.
|
|
151
|
+
Extensions ignore case, the leading dot is optional, and the default `[]` excludes nothing. An entry starting with `/` is a name: a directory with that name is skipped with everything under it, and a file with exactly that name is skipped.
|
|
150
152
|
|
|
151
153
|
## Bookmarks and History
|
|
152
154
|
|
|
@@ -321,8 +323,8 @@ APPLICATIONS = {
|
|
|
321
323
|
:default => 'open'
|
|
322
324
|
}.freeze
|
|
323
325
|
|
|
324
|
-
# Extensions excluded from G mode search
|
|
325
|
-
GLOBAL_SEARCH_EXCLUDED_EXTENSIONS = %w[.log].freeze
|
|
326
|
+
# Extensions (and /names of directories or files) excluded from G mode search
|
|
327
|
+
GLOBAL_SEARCH_EXCLUDED_EXTENSIONS = %w[.log /node_modules].freeze
|
|
326
328
|
```
|
|
327
329
|
|
|
328
330
|
Action names for `KEYBINDS`:
|
data/docs/guide_ja.md
CHANGED
|
@@ -60,6 +60,8 @@ Files モードでは、最上段に現在のパス、その下にブックマ
|
|
|
60
60
|
|
|
61
61
|
## プレビュー
|
|
62
62
|
|
|
63
|
+
テキストのプレビューは先頭64 KiBまで読み込みます。PDFの本文はプレビューせず、ファイル移動時の読み込みを省きます。
|
|
64
|
+
|
|
63
65
|
右側のペインに選択中のファイルを表示します。`bat` があればコードをハイライトします。`.zip` は展開せずにエントリ一覧(サイズ・圧縮方式・更新日時・名前)を表示します。
|
|
64
66
|
|
|
65
67
|
| キー | 操作 |
|
|
@@ -138,15 +140,15 @@ Python・Microsoft Office・変換ツールなしで文書の中も検索でき
|
|
|
138
140
|
|
|
139
141
|
抽出した文字列はメモリにキャッシュし(最大 64 文書・約 16 MiB)、ファイルが変わると再抽出します。画像だけの PDF には OCR が必要で、これは含みません。旧形式の `.xls` / `.ppt` とスライドノートは対象外です。
|
|
140
142
|
|
|
141
|
-
###
|
|
143
|
+
### 拡張子・ディレクトリの除外
|
|
142
144
|
|
|
143
145
|
`~/.config/rufio/config.rb` に次を追加して rufio を再起動します。ファイル名・内容の両方に適用されます。
|
|
144
146
|
|
|
145
147
|
```ruby
|
|
146
|
-
GLOBAL_SEARCH_EXCLUDED_EXTENSIONS = %w[.png .jpg .zip .tar.gz .log].freeze
|
|
148
|
+
GLOBAL_SEARCH_EXCLUDED_EXTENSIONS = %w[.png .jpg .zip .tar.gz .log /.git /node_modules /.DS_Store].freeze
|
|
147
149
|
```
|
|
148
150
|
|
|
149
|
-
大文字・小文字は区別せず、先頭のドットは省略できます。デフォルトの `[]`
|
|
151
|
+
大文字・小文字は区別せず、先頭のドットは省略できます。デフォルトの `[]` は何も除外しません。`/` で始まる要素は名前の指定になり、その名前のディレクトリは配下ごと、名前が完全に一致するファイルは単体で除外します。
|
|
150
152
|
|
|
151
153
|
## ブックマークと履歴
|
|
152
154
|
|
|
@@ -321,8 +323,8 @@ APPLICATIONS = {
|
|
|
321
323
|
:default => 'open'
|
|
322
324
|
}.freeze
|
|
323
325
|
|
|
324
|
-
# G
|
|
325
|
-
GLOBAL_SEARCH_EXCLUDED_EXTENSIONS = %w[.log].freeze
|
|
326
|
+
# G モードの検索から除外する拡張子(/ 始まりはディレクトリ・ファイル名)
|
|
327
|
+
GLOBAL_SEARCH_EXCLUDED_EXTENSIONS = %w[.log /node_modules].freeze
|
|
326
328
|
```
|
|
327
329
|
|
|
328
330
|
`KEYBINDS` で使えるアクション名:
|
data/info/help.md
CHANGED
|
@@ -43,6 +43,9 @@
|
|
|
43
43
|
| `A` | Create new directory |
|
|
44
44
|
|
|
45
45
|
In a preview, `j/k` scroll, `Ctrl+D/U` page down/up and `ESC` returns to the file list.
|
|
46
|
+
|
|
47
|
+
Text previews read at most the first 64 KiB. PDF bodies are not previewed, keeping file navigation responsive.
|
|
48
|
+
|
|
46
49
|
`.zip` previews list the archive entries (size, method, modified time, name) without extracting them.
|
|
47
50
|
|
|
48
51
|
### File Selection & Bulk Operations
|
|
@@ -146,7 +149,7 @@ Search file names or text inside every bookmarked directory.
|
|
|
146
149
|
- Content search also reads `.pdf` (page), `.xlsx` (sheet/cell) and `.pptx` (slide)
|
|
147
150
|
- `Up/Down` or `Ctrl+J/K or Ctrl+N/P`: Select a result | `Enter`: Go to the file in Files mode
|
|
148
151
|
- `Ctrl+U`: Clear the query | `ESC`: Return to the previous mode
|
|
149
|
-
- Exclude extensions with `GLOBAL_SEARCH_EXCLUDED_EXTENSIONS` in `~/.config/rufio/config.rb`
|
|
152
|
+
- Exclude extensions (or `/name` directories and files) with `GLOBAL_SEARCH_EXCLUDED_EXTENSIONS` in `~/.config/rufio/config.rb`
|
|
150
153
|
|
|
151
154
|
### Config Mode (`C`)
|
|
152
155
|
|
data/info/keybindings.md
CHANGED
|
@@ -27,6 +27,8 @@ Complete reference of all keyboard shortcuts in **rufio**.
|
|
|
27
27
|
|
|
28
28
|
`.zip` previews list the archive entries (size, method, modified time, name) without extracting them.
|
|
29
29
|
|
|
30
|
+
Text previews read at most the first 64 KiB. PDF bodies are not previewed, keeping file navigation responsive.
|
|
31
|
+
|
|
30
32
|
## File Selection
|
|
31
33
|
|
|
32
34
|
| Key | Action |
|
data/info/welcome.md
CHANGED
|
@@ -16,6 +16,8 @@ A TUI file manager that executes and coordinates tools and scripts from files.
|
|
|
16
16
|
- **Job Management** - Run commands in the background
|
|
17
17
|
- **Plugin System** - Extend functionality with custom plugins
|
|
18
18
|
|
|
19
|
+
Text previews read at most the first 64 KiB. PDF bodies are not previewed, keeping file navigation responsive.
|
|
20
|
+
|
|
19
21
|
## Quick Start
|
|
20
22
|
|
|
21
23
|
| Key | Action |
|
data/lib/rufio/config_loader.rb
CHANGED
|
@@ -110,9 +110,9 @@ module Rufio
|
|
|
110
110
|
|
|
111
111
|
values.select { |value| value.is_a?(String) }.map do |value|
|
|
112
112
|
extension = value.strip.downcase
|
|
113
|
-
next if extension.empty? || extension == '.'
|
|
113
|
+
next if extension.empty? || extension == '.' || extension == '/'
|
|
114
114
|
|
|
115
|
-
extension.start_with?('.') ? extension : ".#{extension}"
|
|
115
|
+
extension.start_with?('.', '/') ? extension : ".#{extension}"
|
|
116
116
|
end.compact.uniq
|
|
117
117
|
end
|
|
118
118
|
|
data/lib/rufio/file_preview.rb
CHANGED
|
@@ -5,6 +5,7 @@ module Rufio
|
|
|
5
5
|
BINARY_THRESHOLD = 0.3 # treat as binary if 30% or more binary characters
|
|
6
6
|
DEFAULT_MAX_LINES = 50
|
|
7
7
|
MAX_LINE_LENGTH = 500
|
|
8
|
+
MAX_PREVIEW_BYTES = 64 * 1024
|
|
8
9
|
|
|
9
10
|
# Binary detection constants
|
|
10
11
|
BINARY_SAMPLE_SIZE = 512
|
|
@@ -28,6 +29,9 @@ module Rufio
|
|
|
28
29
|
return empty_response if file_size == 0
|
|
29
30
|
|
|
30
31
|
begin
|
|
32
|
+
# PDF bodies are not displayed; never scan their binary streams as text.
|
|
33
|
+
return binary_response(file_path) if File.extname(file_path).casecmp?('.pdf')
|
|
34
|
+
|
|
31
35
|
return archive_response(file_path, max_lines) if zip_archive?(file_path)
|
|
32
36
|
|
|
33
37
|
# binary file detection
|
|
@@ -102,71 +106,30 @@ module Rufio
|
|
|
102
106
|
end
|
|
103
107
|
|
|
104
108
|
def read_text_file(file_path, max_lines)
|
|
109
|
+
# Bound the read before decoding or splitting: a binary stream or minified
|
|
110
|
+
# file can contain a single line spanning the entire file.
|
|
111
|
+
content = File.binread(file_path, MAX_PREVIEW_BYTES + 1)
|
|
112
|
+
truncated = content.bytesize > MAX_PREVIEW_BYTES
|
|
113
|
+
content = content.byteslice(0, MAX_PREVIEW_BYTES) if truncated
|
|
114
|
+
content = content.force_encoding('UTF-8').encode(
|
|
115
|
+
'UTF-8', invalid: :replace, undef: :replace, replace: '?'
|
|
116
|
+
)
|
|
105
117
|
lines = []
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
File.open(file_path, "r:UTF-8:UTF-8", invalid: :replace, undef: :replace, replace: '�') do |file|
|
|
111
|
-
file.each_line.with_index do |line, index|
|
|
112
|
-
break if index >= max_lines
|
|
113
|
-
|
|
114
|
-
begin
|
|
115
|
-
# Ensure line is properly encoded as UTF-8
|
|
116
|
-
line = line.encode('UTF-8', invalid: :replace, undef: :replace, replace: '?')
|
|
117
|
-
|
|
118
|
-
# truncate too long lines
|
|
119
|
-
if line.length > MAX_LINE_LENGTH
|
|
120
|
-
line = line[0...MAX_LINE_LENGTH] + "..."
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
lines << line.chomp
|
|
124
|
-
rescue EncodingError, ArgumentError
|
|
125
|
-
# If encoding fails, add placeholder
|
|
126
|
-
lines << "[encoding error in line #{index + 1}]"
|
|
127
|
-
end
|
|
118
|
+
content.each_line.with_index do |line, index|
|
|
119
|
+
if index >= max_lines
|
|
120
|
+
truncated = true
|
|
121
|
+
break
|
|
128
122
|
end
|
|
129
123
|
|
|
130
|
-
|
|
131
|
-
|
|
124
|
+
line = line[0...MAX_LINE_LENGTH] + '...' if line.length > MAX_LINE_LENGTH
|
|
125
|
+
lines << line.chomp
|
|
132
126
|
end
|
|
133
127
|
|
|
134
128
|
{
|
|
135
129
|
content: lines,
|
|
136
130
|
truncated: truncated,
|
|
137
|
-
encoding:
|
|
131
|
+
encoding: 'UTF-8'
|
|
138
132
|
}
|
|
139
|
-
rescue Encoding::InvalidByteSequenceError, Encoding::CompatibilityError
|
|
140
|
-
# try Shift_JIS if UTF-8 fails
|
|
141
|
-
begin
|
|
142
|
-
lines = []
|
|
143
|
-
File.open(file_path, "r:Shift_JIS:UTF-8", invalid: :replace, undef: :replace, replace: '�') do |file|
|
|
144
|
-
file.each_line.with_index do |line, index|
|
|
145
|
-
break if index >= max_lines
|
|
146
|
-
|
|
147
|
-
begin
|
|
148
|
-
# Ensure line is properly encoded as UTF-8
|
|
149
|
-
line = line.encode('UTF-8', invalid: :replace, undef: :replace, replace: '?')
|
|
150
|
-
lines << line.chomp
|
|
151
|
-
rescue EncodingError, ArgumentError
|
|
152
|
-
# If encoding fails, add placeholder
|
|
153
|
-
lines << "[encoding error in line #{index + 1}]"
|
|
154
|
-
end
|
|
155
|
-
end
|
|
156
|
-
truncated = !file.eof?
|
|
157
|
-
end
|
|
158
|
-
{
|
|
159
|
-
content: lines,
|
|
160
|
-
truncated: truncated,
|
|
161
|
-
encoding: "Shift_JIS"
|
|
162
|
-
}
|
|
163
|
-
rescue
|
|
164
|
-
{
|
|
165
|
-
content: ["(#{ConfigLoader.message('file.encoding_error')})"],
|
|
166
|
-
truncated: false,
|
|
167
|
-
encoding: "unknown"
|
|
168
|
-
}
|
|
169
|
-
end
|
|
170
133
|
end
|
|
171
134
|
|
|
172
135
|
def determine_file_type(file_path)
|
data/lib/rufio/global_search.rb
CHANGED
|
@@ -83,7 +83,7 @@ module Rufio
|
|
|
83
83
|
when :entries
|
|
84
84
|
# Zig 実装の結果はまとめて届くので、1 フレーム 256 件の上限に縛られない
|
|
85
85
|
@entries.concat(value)
|
|
86
|
-
@results.concat(value)
|
|
86
|
+
@results.concat(@search_type == :content || @query.empty? ? value : value.select { |entry| matches_query?(entry) })
|
|
87
87
|
when :error then @errors << value
|
|
88
88
|
when :skipped then @skipped_files += value || 1
|
|
89
89
|
when :done then @scanning = false
|
|
@@ -190,11 +190,14 @@ module Rufio
|
|
|
190
190
|
return unless @scanning
|
|
191
191
|
|
|
192
192
|
roots, events, type, query = @roots, @events, @search_type, @query.downcase
|
|
193
|
-
|
|
193
|
+
native = self.class.native_search?
|
|
194
|
+
native_cache = native && type == :content ? (@native_cache ||= NativeContentSearch::Cache.new) : nil
|
|
194
195
|
@worker = Thread.new do
|
|
195
196
|
begin
|
|
196
|
-
if
|
|
197
|
+
if native && type == :content && NativeContentSearch.supports_query?(query)
|
|
197
198
|
scan_roots_natively(roots, events, query, native_cache)
|
|
199
|
+
elsif native && type == :filename && NativeContentSearch.files_available?
|
|
200
|
+
scan_files_natively(roots, events)
|
|
198
201
|
else
|
|
199
202
|
scan_roots(roots, events, type, query)
|
|
200
203
|
end
|
|
@@ -223,6 +226,8 @@ module Rufio
|
|
|
223
226
|
|
|
224
227
|
def scan_roots(roots, events, type, query)
|
|
225
228
|
visited_dirs = {}
|
|
229
|
+
excluded_names = @excluded_extensions.select { |e| e.start_with?('/') }.map { |e| e.delete_prefix('/') }
|
|
230
|
+
excluded_suffixes = @excluded_extensions.reject { |e| e.start_with?('/') }
|
|
226
231
|
roots.each do |name, root|
|
|
227
232
|
begin
|
|
228
233
|
root = File.expand_path(root)
|
|
@@ -238,11 +243,12 @@ module Rufio
|
|
|
238
243
|
begin
|
|
239
244
|
Dir.each_child(directory) do |child|
|
|
240
245
|
path = File.join(directory, child)
|
|
246
|
+
folded_child = child.scrub.downcase
|
|
241
247
|
# ディレクトリシンボリックリンクは追わず、循環・範囲外の走査を防ぐ。
|
|
242
248
|
if File.directory?(path)
|
|
243
|
-
pending << path unless File.symlink?(path)
|
|
249
|
+
pending << path unless File.symlink?(path) || excluded_names.include?(folded_child)
|
|
244
250
|
elsif File.file?(path)
|
|
245
|
-
if
|
|
251
|
+
if excluded_names.include?(folded_child) || (!excluded_suffixes.empty? && folded_child.end_with?(*excluded_suffixes))
|
|
246
252
|
events << [:skipped, nil]
|
|
247
253
|
next
|
|
248
254
|
end
|
|
@@ -265,6 +271,39 @@ module Rufio
|
|
|
265
271
|
end
|
|
266
272
|
end
|
|
267
273
|
|
|
274
|
+
# Zig 実装でブックマーク配下のファイル一覧を並列走査する
|
|
275
|
+
def scan_files_natively(roots, events)
|
|
276
|
+
valid_roots = []
|
|
277
|
+
roots.each do |name, root|
|
|
278
|
+
root = File.expand_path(root)
|
|
279
|
+
raise Errno::ENOTDIR, root unless File.directory?(root)
|
|
280
|
+
|
|
281
|
+
valid_roots << [name, root]
|
|
282
|
+
rescue SystemCallError, IOError => e
|
|
283
|
+
events << [:error, "#{name}: #{e.message}"]
|
|
284
|
+
end
|
|
285
|
+
return if valid_roots.empty?
|
|
286
|
+
|
|
287
|
+
engine = NativeContentSearch.new(roots: valid_roots.map(&:last), query: nil,
|
|
288
|
+
excluded_extensions: @excluded_extensions)
|
|
289
|
+
engine.each_batch do |batch|
|
|
290
|
+
entries = []
|
|
291
|
+
batch.each do |kind, *args|
|
|
292
|
+
case kind
|
|
293
|
+
when :file, :match
|
|
294
|
+
name, root = valid_roots[args[0]]
|
|
295
|
+
path = args[1]
|
|
296
|
+
entries << { path: path, bookmark: name, relative_path: path.delete_prefix(root + File::SEPARATOR) }
|
|
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
|
+
|
|
268
307
|
# Zig 実装で走査・検索し、PDF などのドキュメントだけ Ruby 側で抽出する
|
|
269
308
|
def scan_roots_natively(roots, events, query, cache)
|
|
270
309
|
valid_roots = []
|
data/lib/rufio/help_menu.rb
CHANGED
|
@@ -55,6 +55,7 @@ module Rufio
|
|
|
55
55
|
"#{key(:move_down)}/#{key(:move_up)} or arrows: Move down/up",
|
|
56
56
|
"#{key(:navigate_parent)}/#{key(:navigate_enter)}: Parent / enter directory",
|
|
57
57
|
"#{key(:preview)}: Fullscreen file preview | ESC: Back to file list",
|
|
58
|
+
'Text preview: first 64 KiB; PDF bodies are not previewed.',
|
|
58
59
|
'Preview: j/k scroll, Ctrl+D/U page down/up',
|
|
59
60
|
"#{key(:top)}/#{key(:bottom)}: First / last entry",
|
|
60
61
|
"#{key(:open_file)}: Open in external editor",
|
|
@@ -99,9 +100,10 @@ module Rufio
|
|
|
99
100
|
'Yellow Searching... + spinner: Search is running.',
|
|
100
101
|
'Green Search complete: All bookmarked directories scanned.',
|
|
101
102
|
'Exclude extensions in ~/.config/rufio/config.rb:',
|
|
102
|
-
'GLOBAL_SEARCH_EXCLUDED_EXTENSIONS = %w[.zip .log]',
|
|
103
|
+
'GLOBAL_SEARCH_EXCLUDED_EXTENSIONS = %w[.zip .log /node_modules]',
|
|
103
104
|
'Applies to names and contents. Restart Rufio after editing.',
|
|
104
105
|
'Extensions ignore case; leading dots are optional; [] excludes none.',
|
|
106
|
+
'/name skips a directory with that name, or a file with exactly that name.',
|
|
105
107
|
'Type to filter | Up/Down or Ctrl+J/K or Ctrl+N/P: Select',
|
|
106
108
|
'Enter: Go to the selected file in Files mode',
|
|
107
109
|
'ESC: Return to the previous mode | Ctrl+U: Clear query',
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -50,6 +50,10 @@ module Rufio
|
|
|
50
50
|
NativeScannerZigFFI.search_available?
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
+
def self.files_available?
|
|
54
|
+
NativeScannerZigFFI.files_available?
|
|
55
|
+
end
|
|
56
|
+
|
|
53
57
|
# Zig 側は ASCII のみ大文字小文字を畳み込むため、大文字小文字を持つ非 ASCII 文字は Ruby 実装に任せる
|
|
54
58
|
def self.supports_query?(query)
|
|
55
59
|
return false if query.empty? || query.bytesize > MAX_QUERY_BYTES
|
|
@@ -64,20 +68,33 @@ module Rufio
|
|
|
64
68
|
line[[match_index - 30, 0].max, 200].to_s.strip
|
|
65
69
|
end
|
|
66
70
|
|
|
67
|
-
def initialize(roots:, query
|
|
68
|
-
|
|
71
|
+
def initialize(roots:, query: nil, excluded_extensions: [], document_extensions: [], cache: nil, threads: 0)
|
|
72
|
+
@files_only = query.nil?
|
|
73
|
+
if @files_only
|
|
74
|
+
raise StandardError, 'Zig file search is not available' unless self.class.files_available?
|
|
75
|
+
else
|
|
76
|
+
raise StandardError, 'Zig content search is not available' unless self.class.available?
|
|
77
|
+
end
|
|
69
78
|
|
|
70
79
|
@cache = cache
|
|
71
80
|
@path_encoding = Encoding.find('filesystem')
|
|
72
81
|
roots_buf = pack(roots)
|
|
73
|
-
query_buf = query.b
|
|
74
82
|
excluded_buf = pack(excluded_extensions)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
83
|
+
@handle = if @files_only
|
|
84
|
+
NativeScannerZigFFI.core_files_create(
|
|
85
|
+
roots_buf, roots_buf.bytesize,
|
|
86
|
+
excluded_buf, excluded_buf.bytesize,
|
|
87
|
+
threads
|
|
88
|
+
)
|
|
89
|
+
else
|
|
90
|
+
query_buf = query.b
|
|
91
|
+
documents_buf = pack(document_extensions)
|
|
92
|
+
NativeScannerZigFFI.core_search_create(
|
|
93
|
+
roots_buf, roots_buf.bytesize, query_buf, query_buf.bytesize,
|
|
94
|
+
excluded_buf, excluded_buf.bytesize, documents_buf, documents_buf.bytesize,
|
|
95
|
+
threads, cache ? cache.handle : 0
|
|
96
|
+
)
|
|
97
|
+
end
|
|
81
98
|
raise StandardError, 'Failed to start native search' if @handle.zero?
|
|
82
99
|
|
|
83
100
|
@length = Fiddle::Pointer.malloc(8, Fiddle::RUBY_FREE)
|
|
@@ -143,7 +160,12 @@ module Rufio
|
|
|
143
160
|
position += extra_length
|
|
144
161
|
|
|
145
162
|
case KINDS[kind]
|
|
146
|
-
when :match
|
|
163
|
+
when :match
|
|
164
|
+
if @files_only
|
|
165
|
+
yield [:file, root, path]
|
|
166
|
+
else
|
|
167
|
+
yield [:match, root, path, line, self.class.snippet(extra, offset)]
|
|
168
|
+
end
|
|
147
169
|
when :document then yield [:document, root, path]
|
|
148
170
|
when :error then yield [:error, root, "#{path}: #{extra.force_encoding(Encoding::UTF_8).scrub}"]
|
|
149
171
|
end
|
|
@@ -101,6 +101,12 @@ module Rufio
|
|
|
101
101
|
@search_available == true
|
|
102
102
|
end
|
|
103
103
|
|
|
104
|
+
# ファイル一覧走査 API(古いライブラリには存在しない)
|
|
105
|
+
def files_available?
|
|
106
|
+
load! unless @loaded
|
|
107
|
+
@files_available == true
|
|
108
|
+
end
|
|
109
|
+
|
|
104
110
|
# ZIP データ処理 API(古いライブラリには存在しない)
|
|
105
111
|
def zip_available?
|
|
106
112
|
load! unless @loaded
|
|
@@ -125,9 +131,16 @@ module Rufio
|
|
|
125
131
|
extern 'uint64_t core_cache_bytes(uint64_t)'
|
|
126
132
|
extern 'void core_cache_destroy(uint64_t)'
|
|
127
133
|
@search_available = true
|
|
134
|
+
begin
|
|
135
|
+
extern 'uint64_t core_files_create(const char*, size_t, const char*, size_t, uint32_t)'
|
|
136
|
+
@files_available = true
|
|
137
|
+
rescue Fiddle::DLError
|
|
138
|
+
@files_available = false
|
|
139
|
+
end
|
|
128
140
|
rescue Fiddle::DLError => e
|
|
129
141
|
warn "Zig content search unavailable: #{e.message}" if ENV['RUFIO_DEBUG']
|
|
130
142
|
@search_available = false
|
|
143
|
+
@files_available = false
|
|
131
144
|
end
|
|
132
145
|
|
|
133
146
|
def load_zip_functions!
|
data/lib/rufio/version.rb
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
//! ripgrep と同条件で比較するためのベンチマーク用 CLI。
|
|
2
|
-
//! 使い方:
|
|
1
|
+
//! ripgrep / fd と同条件で比較するためのベンチマーク用 CLI。
|
|
2
|
+
//! 使い方:
|
|
3
|
+
//! rufio_search [-j N] [-r N] QUERY DIR...
|
|
4
|
+
//! rufio_search --files [-j N] [DIR...]
|
|
3
5
|
//! -r N: キャッシュを使って同じ検索を N 回繰り返し、各回の所要時間を標準エラーに出す(G mode の再検索を再現)
|
|
4
|
-
//! 出力は rg -n と同じ `path:line:text`
|
|
6
|
+
//! 出力は rg -n と同じ `path:line:text` 形式(--files 時は `path` のみ)。
|
|
5
7
|
const std = @import("std");
|
|
6
8
|
const c = std.c;
|
|
7
9
|
const search = @import("search.zig");
|
|
@@ -17,6 +19,7 @@ pub fn main(init: std.process.Init.Minimal) !void {
|
|
|
17
19
|
_ = args.skip();
|
|
18
20
|
var threads: usize = 0;
|
|
19
21
|
var repeat: usize = 0;
|
|
22
|
+
var files_only = false;
|
|
20
23
|
var query: ?[]const u8 = null;
|
|
21
24
|
var roots: std.ArrayList(u8) = .empty;
|
|
22
25
|
const alloc = std.heap.c_allocator;
|
|
@@ -27,18 +30,24 @@ pub fn main(init: std.process.Init.Minimal) !void {
|
|
|
27
30
|
threads = try std.fmt.parseInt(usize, args.next() orelse return error.MissingValue, 10);
|
|
28
31
|
} else if (std.mem.eql(u8, arg, "-r")) {
|
|
29
32
|
repeat = try std.fmt.parseInt(usize, args.next() orelse return error.MissingValue, 10);
|
|
30
|
-
} else if (
|
|
33
|
+
} else if (std.mem.eql(u8, arg, "--files")) {
|
|
34
|
+
files_only = true;
|
|
35
|
+
} else if (!files_only and query == null) {
|
|
31
36
|
query = arg;
|
|
32
37
|
} else {
|
|
33
38
|
if (roots.items.len > 0) try roots.append(alloc, 0);
|
|
34
39
|
try roots.appendSlice(alloc, std.mem.trimEnd(u8, arg, "/"));
|
|
35
40
|
}
|
|
36
41
|
}
|
|
42
|
+
if (roots.items.len == 0) try roots.append(alloc, '.');
|
|
43
|
+
if (files_only) {
|
|
44
|
+
_ = try runSearch(roots.items, null, threads, null, true);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
37
47
|
const q = query orelse {
|
|
38
|
-
writeAll("usage: rufio_search [-j N] [-r N] QUERY DIR...\n");
|
|
48
|
+
writeAll("usage: rufio_search [--files] [-j N] [-r N] [QUERY] DIR...\n");
|
|
39
49
|
return;
|
|
40
50
|
};
|
|
41
|
-
if (roots.items.len == 0) try roots.append(alloc, '.');
|
|
42
51
|
|
|
43
52
|
if (repeat > 0) {
|
|
44
53
|
const cache = try search.Cache.create(256 * 1024 * 1024);
|
|
@@ -56,9 +65,12 @@ pub fn main(init: std.process.Init.Minimal) !void {
|
|
|
56
65
|
_ = try runSearch(roots.items, q, threads, null, true);
|
|
57
66
|
}
|
|
58
67
|
|
|
59
|
-
fn runSearch(roots: []const u8, q: []const u8, threads: usize, cache: ?*search.Cache, print: bool) !usize {
|
|
68
|
+
fn runSearch(roots: []const u8, q: ?[]const u8, threads: usize, cache: ?*search.Cache, print: bool) !usize {
|
|
60
69
|
const alloc = std.heap.c_allocator;
|
|
61
|
-
const s =
|
|
70
|
+
const s = if (q) |query_str|
|
|
71
|
+
try search.Search.create(roots, query_str, "", "", threads, cache)
|
|
72
|
+
else
|
|
73
|
+
try search.Search.createFiles(roots, "/.git\x00/node_modules\x00/.ds_store", threads);
|
|
62
74
|
defer s.destroy();
|
|
63
75
|
|
|
64
76
|
var lines: usize = 0;
|
|
@@ -81,8 +93,10 @@ fn runSearch(roots: []const u8, q: []const u8, threads: usize, cache: ?*search.C
|
|
|
81
93
|
lines += 1;
|
|
82
94
|
if (!print) continue;
|
|
83
95
|
try out.appendSlice(alloc, a);
|
|
84
|
-
|
|
85
|
-
|
|
96
|
+
if (q != null) {
|
|
97
|
+
try out.appendSlice(alloc, try std.fmt.bufPrint(&line_buf, ":{d}:", .{line}));
|
|
98
|
+
try out.appendSlice(alloc, std.mem.trimEnd(u8, b, "\r\n"));
|
|
99
|
+
}
|
|
86
100
|
try out.append(alloc, '\n');
|
|
87
101
|
}
|
|
88
102
|
if (out.items.len >= 1 << 16) {
|
|
@@ -373,6 +373,22 @@ export fn core_search_create(
|
|
|
373
373
|
return @intFromPtr(s);
|
|
374
374
|
}
|
|
375
375
|
|
|
376
|
+
/// ファイル一覧走査(fd 互換)を開始してハンドルを返す(失敗時は 0)。threads=0 で CPU 数
|
|
377
|
+
export fn core_files_create(
|
|
378
|
+
roots: [*]const u8,
|
|
379
|
+
roots_len: usize,
|
|
380
|
+
excluded: [*]const u8,
|
|
381
|
+
excluded_len: usize,
|
|
382
|
+
threads: u32,
|
|
383
|
+
) u64 {
|
|
384
|
+
const s = search.Search.createFiles(
|
|
385
|
+
roots[0..roots_len],
|
|
386
|
+
excluded[0..excluded_len],
|
|
387
|
+
threads,
|
|
388
|
+
) catch return 0;
|
|
389
|
+
return @intFromPtr(s);
|
|
390
|
+
}
|
|
391
|
+
|
|
376
392
|
export fn core_search_state(handle: u64) u8 {
|
|
377
393
|
if (handle == 0) return @intFromEnum(search.State.failed);
|
|
378
394
|
const s: *search.Search = @ptrFromInt(handle);
|
|
@@ -282,6 +282,7 @@ const Job = struct {
|
|
|
282
282
|
|
|
283
283
|
pub const Search = struct {
|
|
284
284
|
matcher: Matcher,
|
|
285
|
+
files_only: bool = false,
|
|
285
286
|
roots: StringList,
|
|
286
287
|
excluded: StringList,
|
|
287
288
|
documents: StringList,
|
|
@@ -313,10 +314,21 @@ pub const Search = struct {
|
|
|
313
314
|
|
|
314
315
|
pub fn create(roots: []const u8, query: []const u8, excluded: []const u8, documents: []const u8, threads: usize, cache: ?*Cache) !*Search {
|
|
315
316
|
if (query.len == 0 or query.len > MAX_NEEDLE or roots.len == 0) return error.InvalidArgument;
|
|
317
|
+
return createInternal(roots, query, excluded, documents, threads, cache, false);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/// ファイル一覧列挙モード(fd 互換・ファイル内容は開かずに高速走査)
|
|
321
|
+
pub fn createFiles(roots: []const u8, excluded: []const u8, threads: usize) !*Search {
|
|
322
|
+
if (roots.len == 0) return error.InvalidArgument;
|
|
323
|
+
return createInternal(roots, "", excluded, "", threads, null, true);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
fn createInternal(roots: []const u8, query: []const u8, excluded: []const u8, documents: []const u8, threads: usize, cache: ?*Cache, files_only: bool) !*Search {
|
|
316
327
|
const self = try std.heap.c_allocator.create(Search);
|
|
317
328
|
errdefer std.heap.c_allocator.destroy(self);
|
|
318
329
|
self.* = .{
|
|
319
330
|
.matcher = Matcher.init(query),
|
|
331
|
+
.files_only = files_only,
|
|
320
332
|
.roots = try StringList.init(roots),
|
|
321
333
|
.excluded = try StringList.init(excluded),
|
|
322
334
|
.documents = try StringList.init(documents),
|
|
@@ -499,7 +511,9 @@ const Worker = struct {
|
|
|
499
511
|
key: Buf = .{},
|
|
500
512
|
|
|
501
513
|
fn init(self: *Worker) !void {
|
|
502
|
-
|
|
514
|
+
if (!self.s.files_only) {
|
|
515
|
+
try self.read.reserve(READ_BUFFER);
|
|
516
|
+
}
|
|
503
517
|
try self.scratch.init();
|
|
504
518
|
try self.out.reserve(LOCAL_FLUSH * 2);
|
|
505
519
|
}
|
|
@@ -520,7 +534,13 @@ const Worker = struct {
|
|
|
520
534
|
}
|
|
521
535
|
|
|
522
536
|
fn record(self: *Worker, kind: Kind, root: u32, line: u64, offset: u32, dir: []const u8, name: []const u8, extra: []const u8) void {
|
|
523
|
-
const
|
|
537
|
+
const clean_dir = if (name.len > 0 and std.mem.eql(u8, dir, "."))
|
|
538
|
+
""
|
|
539
|
+
else if (name.len > 0 and std.mem.startsWith(u8, dir, "./"))
|
|
540
|
+
dir[2..]
|
|
541
|
+
else
|
|
542
|
+
dir;
|
|
543
|
+
const path_len = if (name.len == 0) clean_dir.len else if (clean_dir.len == 0) name.len else clean_dir.len + 1 + name.len;
|
|
524
544
|
self.out.reserve(self.out.len + RECORD_HEADER + path_len + extra.len) catch return;
|
|
525
545
|
var hdr: [RECORD_HEADER]u8 = undefined;
|
|
526
546
|
hdr[0] = @intFromEnum(kind);
|
|
@@ -530,9 +550,13 @@ const Worker = struct {
|
|
|
530
550
|
std.mem.writeInt(u32, hdr[17..21], @intCast(path_len), .little);
|
|
531
551
|
std.mem.writeInt(u32, hdr[21..25], @intCast(extra.len), .little);
|
|
532
552
|
self.out.append(&hdr) catch unreachable;
|
|
533
|
-
|
|
553
|
+
if (clean_dir.len > 0) {
|
|
554
|
+
self.out.append(clean_dir) catch unreachable;
|
|
555
|
+
if (name.len > 0) {
|
|
556
|
+
self.out.append("/") catch unreachable;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
534
559
|
if (name.len > 0) {
|
|
535
|
-
self.out.append("/") catch unreachable;
|
|
536
560
|
self.out.append(name) catch unreachable;
|
|
537
561
|
}
|
|
538
562
|
self.out.append(extra) catch unreachable;
|
|
@@ -542,7 +566,17 @@ const Worker = struct {
|
|
|
542
566
|
self.record(.err, root, 0, 0, dir, name, os.lastErrorMessage());
|
|
543
567
|
}
|
|
544
568
|
|
|
569
|
+
fn isIgnoredDir(self: *Worker, name: []const u8) bool {
|
|
570
|
+
for (self.s.excluded.items) |ext| {
|
|
571
|
+
if (ext.len > 1 and ext[0] == '/' and name.len == ext.len - 1 and hasSuffixIgnoreCase(name, ext[1..])) {
|
|
572
|
+
return true;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
return false;
|
|
576
|
+
}
|
|
577
|
+
|
|
545
578
|
fn pushSubdir(self: *Worker, job: *Job, name: []const u8) void {
|
|
579
|
+
if (self.isIgnoredDir(name)) return;
|
|
546
580
|
const sub = Job.create(job.root, job.path(), name) orelse return;
|
|
547
581
|
sub.next = self.subdirs;
|
|
548
582
|
if (self.subdirs == null) self.subdirs_tail = sub;
|
|
@@ -586,11 +620,24 @@ const Worker = struct {
|
|
|
586
620
|
|
|
587
621
|
fn handleFile(self: *Worker, dir: *os.Dir, job: *Job, name: [:0]const u8, size: ?u64, meta: ?FileMeta) void {
|
|
588
622
|
for (self.s.excluded.items) |ext| {
|
|
589
|
-
if (ext.len
|
|
623
|
+
if (ext.len == 0) continue;
|
|
624
|
+
if (ext[0] == '/') {
|
|
625
|
+
if (name.len == ext.len - 1 and hasSuffixIgnoreCase(name, ext[1..])) {
|
|
626
|
+
_ = self.s.skipped.fetchAdd(1, .monotonic);
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
} else if (hasSuffixIgnoreCase(name, ext)) {
|
|
590
630
|
_ = self.s.skipped.fetchAdd(1, .monotonic);
|
|
591
631
|
return;
|
|
592
632
|
}
|
|
593
633
|
}
|
|
634
|
+
|
|
635
|
+
// ファイル一覧モード(fd 互換): ファイルを開かずにパスのみ記録
|
|
636
|
+
if (self.s.files_only) {
|
|
637
|
+
self.record(.match, job.root, 1, 0, job.path(), name, "");
|
|
638
|
+
if (self.out.len >= LOCAL_FLUSH) self.s.publish(&self.out);
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
594
641
|
const ext = extname(name);
|
|
595
642
|
if (ext.len > 0) {
|
|
596
643
|
for (self.s.documents.items) |doc| {
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rufio
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- masisz
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-11-
|
|
11
|
+
date: 2026-11-14 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: io-console
|