rufio 0.71.0 → 0.80.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b269821deb0be4866afdc75e58a1369de3448324cee49e6c4f7e68febbae9fff
4
- data.tar.gz: 835570061c06d6a5adc439d2dfb67c62313fccf101cdb6fb068976f5d72d5607
3
+ metadata.gz: a5c4fc956ed9bcb90569c8c7fcdd023fec3d31cdf1852f075ba40c9746a654dc
4
+ data.tar.gz: f07ae9f1f0071de307b407e5322f3f2a53c62fb654f023223f7214be79350dc3
5
5
  SHA512:
6
- metadata.gz: 8ba615e62db5874737085807f29825e92c3447a094aeb7349c77b8d0adab4e39fc2ce4bfe40fa53f354b23057e81a29077a7177a6f31379cf140ff876bd2bbc7
7
- data.tar.gz: b8ac577e995fb0a1cbc7776246f3443e2d137914a84fa94b02b013a3bca5f9124b30b42b03cb6fedbe8bb5d33fcadfbea4d9622023b446df5abb5288871bf978
6
+ metadata.gz: '097aed068bd564b961c159504b7396c4d0dec7b9eb4be819d2f83380fc10aa86e698c316b9e95bfb012863790ea53cbe0ee521d3561919cdde61fbafcbb22959'
7
+ data.tar.gz: 6d1d2ce8640debc3fe6fbe282e75a959bb4c7a846f21cde65d0a4cd2a713329c7416591c2d182b05e7bd67b4df9895071cac32dc9ab85940459eed8f19660881
data/CHANGELOG.md CHANGED
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.80.0] - 2026-02-21
11
+
12
+ ### Added
13
+ - **Syntax Highlighting**: File preview now supports syntax highlighting via `bat`
14
+ - 19 languages supported: Ruby, Python, JS, TS, Go, Rust, Shell, C, C++, Java, SQL, TOML, YAML, JSON, HTML, CSS, Markdown, Dockerfile, Makefile
15
+ - Graceful fallback to plain text when `bat` is not installed
16
+ - mtime-based cache for instant re-display of previously viewed files
17
+ - Health check support (`rufio -c`) now reports `bat` availability
18
+ - **New classes**: `AnsiLineParser`, `SyntaxHighlighter`
19
+ - **New tests**: `test_ansi_line_parser.rb` (25 tests), `test_syntax_highlighter.rb` (+7 async tests)
20
+
21
+ ### Fixed
22
+ - **Cursor flickering** when navigating source directories: Changed `Renderer#render` from per-line `print` (immediate flush) to a single buffered `write` call — terminal updates are now atomic
23
+ - **Navigation lag** when moving between source files: `bat` is now executed asynchronously in a background thread; the current frame immediately falls back to plain text, then re-renders with highlighting on completion
24
+
25
+ ### Changed
26
+ - **`Renderer#render`**: Replaced row-by-row `print` with single `write(buf)` + `flush` for atomic output
27
+ - **`SyntaxHighlighter`**: Added `highlight_async` (Thread + Mutex + pending guard) alongside existing `highlight`
28
+ - **`TerminalUI`**: Preview caching extended with `highlighted` / `highlighted_wrapped` keys; `@highlight_updated` flag added for async re-render notification
29
+ - **`FilePreview#determine_file_type`**: Added Go, Rust, Shell, TOML, SQL, C, C++, Java, Dockerfile, Makefile language detection
30
+
31
+ ### Technical Details
32
+ - **New files**: `lib/rufio/ansi_line_parser.rb`, `lib/rufio/syntax_highlighter.rb`
33
+ - **Modified files**: `lib/rufio/renderer.rb`, `lib/rufio/terminal_ui.rb`, `lib/rufio/file_preview.rb`, `lib/rufio/health_checker.rb`, `lib/rufio/config.rb`, `lib/rufio.rb`
34
+ - **For details**: [CHANGELOG_v0.80.0.md](./docs/CHANGELOG_v0.80.0.md)
35
+
10
36
  ## [0.71.0] - 2026-02-16
11
37
 
12
38
  ### Added
@@ -611,6 +637,7 @@ For detailed information, see [CHANGELOG_v0.4.0.md](./docs/CHANGELOG_v0.4.0.md)
611
637
 
612
638
  ### Detailed Release Notes
613
639
 
640
+ - [v0.80.0](./docs/CHANGELOG_v0.80.0.md) - Syntax Highlighting & Rendering Fixes
614
641
  - [v0.31.0](./docs/CHANGELOG_v0.31.0.md) - Experimental Native Scanner Implementation
615
642
  - [v0.30.0](./docs/CHANGELOG_v0.30.0.md) - Help System Overhaul
616
643
  - [v0.21.0](./docs/CHANGELOG_v0.21.0.md) - Copy Feature & Code Refactoring
data/README.md CHANGED
@@ -40,7 +40,7 @@ rufio is not just a file manager. It's a **tool runtime execution environment**.
40
40
  ### As a File Manager
41
41
 
42
42
  - **Vim-like Key Bindings**: Intuitive navigation
43
- - **Real-time Preview**: Instantly display file contents
43
+ - **Real-time Preview**: Instantly display file contents with syntax highlighting (via `bat`)
44
44
  - **Fast Search**: Integration with fzf/rga
45
45
  - **Bookmarks**: Quick access to frequently used directories
46
46
  - **zoxide Integration**: Smart directory history
@@ -290,22 +290,41 @@ script_paths:
290
290
 
291
291
  ## External Tool Integration
292
292
 
293
- rufio integrates with the following external tools to extend functionality:
293
+ rufio integrates with the following external tools to extend functionality.
294
+ All tools are **optional** — rufio works without them, falling back to built-in behavior.
294
295
 
295
- | Tool | Purpose | Key |
296
- |------|---------|-----|
297
- | fzf | File name search | `s` |
298
- | rga | File content search | `F` |
299
- | zoxide | Directory history | `z` |
296
+ | Tool | Purpose | Key | Required? |
297
+ |------|---------|-----|-----------|
298
+ | fzf | File name search | `s` | Optional |
299
+ | rga | File content search | `F` | Optional |
300
+ | zoxide | Directory history | `z` | Optional |
301
+ | bat | Syntax highlighting in preview | — | Optional |
300
302
 
301
- ### Installation
303
+ ### bat — Syntax Highlighting
304
+
305
+ When `bat` is installed, code files in the preview pane are displayed with full syntax
306
+ highlighting (Ruby, Python, Go, Rust, TypeScript, and 15+ more languages).
307
+
308
+ Highlighting is loaded asynchronously — navigation stays fast even on large source trees.
309
+
310
+ ```bash
311
+ # macOS
312
+ brew install bat
313
+
314
+ # Ubuntu/Debian
315
+ apt install bat
316
+ ```
317
+
318
+ Run `rufio -c` to verify bat is detected correctly.
319
+
320
+ ### Installation (all tools)
302
321
 
303
322
  ```bash
304
323
  # macOS
305
- brew install fzf rga zoxide
324
+ brew install fzf bat rga zoxide
306
325
 
307
326
  # Ubuntu/Debian
308
- apt install fzf zoxide
327
+ apt install fzf bat zoxide
309
328
  # rga requires separate installation: https://github.com/phiresky/ripgrep-all
310
329
  ```
311
330
 
data/README_ja.md CHANGED
@@ -42,7 +42,7 @@ rufioは単なるファイルマネージャーではありません。**ツー
42
42
  ### ファイルマネージャーとして
43
43
 
44
44
  - **Vimライクなキーバインド**: 直感的なナビゲーション
45
- - **リアルタイムプレビュー**: ファイル内容を即座に表示
45
+ - **リアルタイムプレビュー**: ファイル内容を即座に表示(`bat` によるシンタックスハイライト対応)
46
46
  - **高速検索**: fzf/rgaとの連携
47
47
  - **ブックマーク**: よく使うディレクトリに素早くアクセス
48
48
  - **zoxide連携**: スマートなディレクトリ履歴
@@ -255,22 +255,42 @@ SCRIPT_PATHS = [
255
255
 
256
256
  ## 外部ツール連携
257
257
 
258
- rufioは以下の外部ツールと連携して機能を拡張します:
258
+ rufioは以下の外部ツールと連携して機能を拡張します。
259
+ すべて**オプション**です。インストールなしでも rufio は正常動作します。
259
260
 
260
- | ツール | 用途 | キー |
261
- |--------|------|------|
262
- | fzf | ファイル名検索 | `s` |
263
- | rga | ファイル内容検索 | `F` |
264
- | zoxide | ディレクトリ履歴 | `z` |
261
+ | ツール | 用途 | キー | 必須 |
262
+ |--------|------|------|------|
263
+ | fzf | ファイル名検索 | `s` | オプション |
264
+ | rga | ファイル内容検索 | `F` | オプション |
265
+ | zoxide | ディレクトリ履歴 | `z` | オプション |
266
+ | bat | プレビューのシンタックスハイライト | — | オプション |
265
267
 
266
- ### インストール
268
+ ### bat — シンタックスハイライト
269
+
270
+ `bat` をインストールすると、プレビューペインでコードファイルを開いた際に
271
+ シンタックスハイライトが表示されます(Ruby、Python、Go、Rust、TypeScript など15言語以上対応)。
272
+
273
+ ハイライトはバックグラウンドで非同期に読み込まれるため、大きなソースツリーでも
274
+ カーソル移動が重くなりません。
275
+
276
+ ```bash
277
+ # macOS
278
+ brew install bat
279
+
280
+ # Ubuntu/Debian
281
+ apt install bat
282
+ ```
283
+
284
+ `rufio -c` を実行すると bat が正しく認識されているか確認できます。
285
+
286
+ ### インストール(全ツール)
267
287
 
268
288
  ```bash
269
289
  # macOS
270
- brew install fzf rga zoxide
290
+ brew install fzf bat rga zoxide
271
291
 
272
292
  # Ubuntu/Debian
273
- apt install fzf zoxide
293
+ apt install fzf bat zoxide
274
294
  # rgaは別途インストール: https://github.com/phiresky/ripgrep-all
275
295
  ```
276
296
 
@@ -0,0 +1,189 @@
1
+ # CHANGELOG v0.80.0
2
+
3
+ ## Overview
4
+
5
+ Adds syntax highlighting to the file preview pane. When `bat` is installed, code files
6
+ are displayed with full ANSI color highlighting. Environments without `bat` fall back
7
+ silently to plain text display.
8
+
9
+ Also fixes **cursor flickering** and **navigation lag** that occurred when browsing
10
+ source code directories with highlighting active.
11
+
12
+ ---
13
+
14
+ ## New Features
15
+
16
+ ### Syntax Highlighting (via `bat`)
17
+
18
+ The preview pane now renders code files with syntax highlighting using `bat`'s ANSI
19
+ color output.
20
+
21
+ **Supported languages:**
22
+
23
+ | Language | Extensions |
24
+ |----------|------------|
25
+ | Ruby | `.rb` |
26
+ | Python | `.py` |
27
+ | JavaScript | `.js`, `.mjs` |
28
+ | TypeScript | `.ts` |
29
+ | HTML | `.html`, `.htm` |
30
+ | CSS | `.css` |
31
+ | JSON | `.json` |
32
+ | YAML | `.yml`, `.yaml` |
33
+ | Markdown | `.md`, `.markdown` |
34
+ | Go | `.go` |
35
+ | Rust | `.rs` |
36
+ | Shell | `.sh`, `.bash`, `.zsh` |
37
+ | TOML | `.toml` |
38
+ | SQL | `.sql` |
39
+ | C | `.c`, `.h` |
40
+ | C++ | `.cpp`, `.cc`, `.cxx`, `.hpp` |
41
+ | Java | `.java` |
42
+ | Dockerfile | `Dockerfile`, `Dockerfile.*` |
43
+ | Makefile | `Makefile`, `GNUmakefile` |
44
+
45
+ **Behavior:**
46
+ - Graceful degradation — plain text display when `bat` is not installed
47
+ - Non-UTF-8 files (e.g. Shift_JIS) skip highlighting automatically
48
+ - mtime-based cache: second visit to the same file is instant (0 ms)
49
+ - Run `rufio -c` to verify `bat` is detected by the health checker
50
+
51
+ **Installation:**
52
+ ```bash
53
+ # macOS
54
+ brew install bat
55
+
56
+ # Ubuntu/Debian
57
+ apt install bat
58
+ ```
59
+
60
+ ---
61
+
62
+ ## Bug Fixes
63
+
64
+ ### Fix 1: Cursor Flickering (Renderer Atomic Output)
65
+
66
+ **Symptom:** The preview pane flickered briefly when moving the cursor in source
67
+ code directories.
68
+
69
+ **Root cause:** `Renderer#render` called `print` once per dirty row. With
70
+ `STDOUT sync=true`, each `print` flushes immediately, so intermediate states
71
+ (highlighted color removed, new color not yet drawn) were visible in the terminal.
72
+
73
+ **Fix:** All dirty row output is now accumulated into a single string buffer, then
74
+ written with one `write` + `flush` call — guaranteeing an atomic terminal update.
75
+
76
+ ```
77
+ Before: print row0 → flush → print row1 → flush → ... (intermediate states visible)
78
+ After: buf = row0 + row1 + ... → write(buf) + flush (single atomic update)
79
+ ```
80
+
81
+ ### Fix 2: Navigation Lag (Async bat Execution)
82
+
83
+ **Symptom:** Moving the cursor to a new file in a source directory caused 10–30 ms
84
+ of lag, making navigation feel sluggish.
85
+
86
+ **Root cause:** `SyntaxHighlighter#highlight` called `IO.popen(['bat', ...])` synchronously
87
+ inside the main loop. The bat process startup cost blocked the frame on every new file visit.
88
+
89
+ **Fix:** Added `highlight_async` which runs `bat` in a background Thread.
90
+
91
+ - The frame immediately after moving to a new file displays plain text (instant fallback)
92
+ - When the background thread completes, it sets `@highlight_updated = true`
93
+ - The main loop detects the flag and triggers a re-render with highlighting
94
+ - A pending guard prevents duplicate threads for the same file path
95
+ - A `Mutex` protects all cache reads/writes for thread safety
96
+
97
+ ---
98
+
99
+ ## Technical Details
100
+
101
+ ### New Files
102
+
103
+ | File | Description |
104
+ |------|-------------|
105
+ | `lib/rufio/ansi_line_parser.rb` | Parses ANSI SGR escape sequences into token arrays. Full-width character-aware wrapping |
106
+ | `lib/rufio/syntax_highlighter.rb` | Wraps the `bat` command. mtime cache, async execution, Mutex protection |
107
+ | `test/test_ansi_line_parser.rb` | Unit tests for AnsiLineParser (25 tests) |
108
+ | `test/test_syntax_highlighter.rb` | Unit tests for SyntaxHighlighter (16 tests) |
109
+
110
+ ### Modified Files
111
+
112
+ | File | Change |
113
+ |------|--------|
114
+ | `lib/rufio/renderer.rb` | Per-line `print` → single `write(buf)` + `flush` |
115
+ | `lib/rufio/terminal_ui.rb` | Added `@syntax_highlighter`, highlighting branch in `draw_file_preview_to_buffer`, `@highlight_updated` check in main loop |
116
+ | `lib/rufio/file_preview.rb` | Extended `determine_file_type` with Go, Rust, Shell, TOML, SQL, C, C++, Java, Dockerfile, Makefile |
117
+ | `lib/rufio/health_checker.rb` | Added `check_bat` method |
118
+ | `lib/rufio/config.rb` | Added `health.bat` message key (EN + JA) |
119
+ | `lib/rufio.rb` | Added `require` for `ansi_line_parser` and `syntax_highlighter` |
120
+ | `test/test_renderer.rb` | Added `OutputSpy` helper, 2 new atomic output tests |
121
+
122
+ ### Architecture
123
+
124
+ ```
125
+ bat (external process)
126
+ ↓ IO.popen — background Thread
127
+ SyntaxHighlighter#highlight_async
128
+ ↓ callback on completion
129
+ @preview_cache[path][:highlighted] = lines # store ANSI line array
130
+ @highlight_updated = true # notify main loop
131
+ ↓ next frame
132
+ AnsiLineParser.parse(line) # ANSI → token array
133
+ AnsiLineParser.wrap(tokens, width) # full-width-aware wrapping
134
+ draw_highlighted_line_to_buffer(screen, ...) # per-char fg: color drawing
135
+ ```
136
+
137
+ ### Preview Cache Structure
138
+
139
+ ```ruby
140
+ @preview_cache[file_path] = {
141
+ content: Array<String>, # plain text lines
142
+ preview_data: Hash, # type, encoding, etc.
143
+ highlighted: nil | false | Array<String>,
144
+ # nil = not yet requested
145
+ # false = requested, awaiting background result
146
+ # Array = ANSI lines ready to render
147
+ wrapped: Hash, # width => wrapped plain lines
148
+ highlighted_wrapped: Hash # width => wrapped token arrays
149
+ }
150
+ ```
151
+
152
+ ---
153
+
154
+ ## Tests
155
+
156
+ All tests pass (pre-existing TestUISnapshot / TestBufferParity snapshot mismatches excluded):
157
+
158
+ | Test file | Tests | Status |
159
+ |-----------|-------|--------|
160
+ | `test_ansi_line_parser.rb` | 25 | new |
161
+ | `test_syntax_highlighter.rb` | 16 (9 existing + 7 new) | pass |
162
+ | `test_renderer.rb` | 12 (10 existing + 2 new) | pass |
163
+
164
+ **34 new tests** added in this release.
165
+
166
+ ---
167
+
168
+ ## Dependencies
169
+
170
+ ### New Optional External Tool
171
+
172
+ | Tool | Purpose | Install |
173
+ |------|---------|---------|
174
+ | `bat` | Syntax highlighting | `brew install bat` / `apt install bat` |
175
+
176
+ rufio works normally without `bat` — plain text preview is always available as a fallback.
177
+
178
+ ---
179
+
180
+ ## Health Check
181
+
182
+ Use `rufio -c` to verify `bat` installation:
183
+
184
+ ```
185
+ rufio Health Check
186
+ ✓ bat (syntax highlight): bat 0.25.0 (2024-...)
187
+ ✗ bat (syntax highlight): not found
188
+ brew install bat # optional: enables syntax highlighting
189
+ ```
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rufio
4
+ # ANSI エスケープコード付きの行をトークン列に分解するモジュール。
5
+ # bat --color=always の出力をシンタックスハイライト表示するために使用する。
6
+ module AnsiLineParser
7
+ # SGR (Select Graphic Rendition) ANSI エスケープシーケンスにマッチするパターン
8
+ ANSI_SGR_PATTERN = /\e\[[0-9;]*m/
9
+
10
+ # ANSI 付き行を {text: String, fg: String|nil} のトークン配列に分解する。
11
+ #
12
+ # @param line [String] ANSI コードを含む可能性がある行
13
+ # @return [Array<Hash>] {text:, fg:} のトークン配列
14
+ def self.parse(line)
15
+ tokens = []
16
+ current_fg = nil
17
+
18
+ # ANSI SGR シーケンス、非エスケープ文字列、孤立エスケープの順にスキャン
19
+ line.scan(/#{ANSI_SGR_PATTERN}|[^\e]+|\e(?!\[)/) do |part|
20
+ if part.start_with?("\e[")
21
+ current_fg = apply_ansi_sequence(current_fg, part)
22
+ else
23
+ tokens << { text: part, fg: current_fg }
24
+ end
25
+ end
26
+
27
+ tokens
28
+ end
29
+
30
+ # トークン配列の表示幅(ANSI コードを除く)を計算する。
31
+ #
32
+ # @param tokens [Array<Hash>] parse が返したトークン配列
33
+ # @return [Integer] 表示幅
34
+ def self.display_width(tokens)
35
+ tokens.sum { |t| TextUtils.display_width(t[:text]) }
36
+ end
37
+
38
+ # トークン配列を max_width で折り返し、行ごとのトークン配列の配列を返す。
39
+ # 全角文字(日本語等)は幅2として扱う。
40
+ #
41
+ # @param tokens [Array<Hash>] parse が返したトークン配列
42
+ # @param max_width [Integer] 折り返し幅(表示幅基準)
43
+ # @return [Array<Array<Hash>>] 行ごとのトークン配列
44
+ def self.wrap(tokens, max_width)
45
+ return [] if tokens.empty? || max_width <= 0
46
+
47
+ lines = []
48
+ current_line = []
49
+ current_width = 0
50
+
51
+ tokens.each do |token|
52
+ fg = token[:fg]
53
+ current_text = String.new
54
+
55
+ token[:text].each_char do |char|
56
+ char_w = TextUtils.char_width(char)
57
+
58
+ if current_width + char_w > max_width
59
+ # 折り返し: 現在のテキストをトークンとして確定
60
+ current_line << { text: current_text, fg: fg } unless current_text.empty?
61
+ lines << current_line
62
+ current_line = []
63
+ current_text = String.new
64
+ current_width = 0
65
+ end
66
+
67
+ current_text << char
68
+ current_width += char_w
69
+ end
70
+
71
+ current_line << { text: current_text, fg: fg } unless current_text.empty?
72
+ end
73
+
74
+ lines << current_line unless current_line.empty?
75
+ lines
76
+ end
77
+
78
+ # private helper: ANSI シーケンスを適用して新しい fg 状態を返す。
79
+ # リセットシーケンスは nil を返す。
80
+ # 複合シーケンス(例: \e[0;32m)はリセット後の色だけを返す。
81
+ def self.apply_ansi_sequence(current_fg, seq)
82
+ # \e[ と m の間のコード文字列を取り出す
83
+ codes_str = seq[2..-2]
84
+ codes = codes_str.split(';')
85
+
86
+ # すべてのコードがリセット("0" or "")ならリセット
87
+ if codes.empty? || codes.all? { |c| c.empty? || c == '0' }
88
+ return nil
89
+ end
90
+
91
+ # 先頭がリセットコードで後続に色指定がある場合(例: \e[0;32m)
92
+ if codes.first.empty? || codes.first == '0'
93
+ remaining = codes.drop_while { |c| c.empty? || c == '0' }
94
+ return remaining.empty? ? nil : "\e[#{remaining.join(';')}m"
95
+ end
96
+
97
+ # 通常の色/属性コード → そのまま使用
98
+ seq
99
+ end
100
+
101
+ private_class_method :apply_ansi_sequence
102
+ end
103
+ end
data/lib/rufio/config.rb CHANGED
@@ -66,6 +66,7 @@ module Rufio
66
66
  'health.fzf' => 'fzf (file search)',
67
67
  'health.rga' => 'rga (content search)',
68
68
  'health.zoxide' => 'zoxide (directory history)',
69
+ 'health.bat' => 'bat (syntax highlight)',
69
70
  'health.file_opener' => 'System file opener',
70
71
  'health.summary' => 'Summary:',
71
72
  'health.ok' => 'OK',
@@ -135,6 +136,7 @@ module Rufio
135
136
  'health.fzf' => 'fzf (file search)',
136
137
  'health.rga' => 'rga (content search)',
137
138
  'health.zoxide' => 'zoxide (directory history)',
139
+ 'health.bat' => 'bat (syntax highlight)',
138
140
  'health.file_opener' => 'System file opener',
139
141
  'health.summary' => 'Summary:',
140
142
  'health.ok' => 'OK',
@@ -127,7 +127,16 @@ module Rufio
127
127
 
128
128
  def determine_file_type(file_path)
129
129
  extension = File.extname(file_path).downcase
130
-
130
+ basename = File.basename(file_path)
131
+
132
+ # 拡張子なしファイル名での判定(Dockerfile 等)
133
+ case basename
134
+ when "Dockerfile", /\ADockerfile\./
135
+ return { type: "code", language: "dockerfile" }
136
+ when "Makefile", "GNUmakefile"
137
+ return { type: "code", language: "makefile" }
138
+ end
139
+
131
140
  case extension
132
141
  when ".rb"
133
142
  { type: "code", language: "ruby" }
@@ -147,6 +156,22 @@ module Rufio
147
156
  { type: "code", language: "yaml" }
148
157
  when ".md", ".markdown"
149
158
  { type: "code", language: "markdown" }
159
+ when ".go"
160
+ { type: "code", language: "go" }
161
+ when ".rs"
162
+ { type: "code", language: "rust" }
163
+ when ".sh", ".bash", ".zsh"
164
+ { type: "code", language: "shell" }
165
+ when ".toml"
166
+ { type: "code", language: "toml" }
167
+ when ".sql"
168
+ { type: "code", language: "sql" }
169
+ when ".c", ".h"
170
+ { type: "code", language: "c" }
171
+ when ".cpp", ".cc", ".cxx", ".hpp"
172
+ { type: "code", language: "cpp" }
173
+ when ".java"
174
+ { type: "code", language: "java" }
150
175
  when ".txt", ".log"
151
176
  { type: "text", language: nil }
152
177
  when ".zip", ".tar", ".gz", ".bz2", ".xz", ".7z"
@@ -21,6 +21,7 @@ module Rufio
21
21
  { name: ConfigLoader.message('health.fzf'), method: :check_fzf },
22
22
  { name: ConfigLoader.message('health.rga'), method: :check_rga },
23
23
  { name: ConfigLoader.message('health.zoxide'), method: :check_zoxide },
24
+ { name: ConfigLoader.message('health.bat'), method: :check_bat },
24
25
  { name: ConfigLoader.message('health.file_opener'), method: :check_file_opener }
25
26
  ]
26
27
 
@@ -136,6 +137,23 @@ module Rufio
136
137
  end
137
138
  end
138
139
 
140
+ def check_bat
141
+ if system("which bat > /dev/null 2>&1")
142
+ version = `bat --version 2>/dev/null`.strip
143
+ {
144
+ status: :ok,
145
+ message: version,
146
+ details: nil
147
+ }
148
+ else
149
+ {
150
+ status: :warning,
151
+ message: "bat #{ConfigLoader.message('health.tool_not_found')}",
152
+ details: install_instruction_for('bat')
153
+ }
154
+ end
155
+ end
156
+
139
157
  def check_file_opener
140
158
  case RUBY_PLATFORM
141
159
  when /darwin/
@@ -180,6 +198,8 @@ module Rufio
180
198
  "#{ConfigLoader.message('health.install_brew')} rga"
181
199
  when 'zoxide'
182
200
  "#{ConfigLoader.message('health.install_brew')} zoxide"
201
+ when 'bat'
202
+ "#{ConfigLoader.message('health.install_brew')} bat # optional: syntax highlight"
183
203
  end
184
204
  when /linux/
185
205
  case tool
@@ -189,6 +209,8 @@ module Rufio
189
209
  ConfigLoader.message('health.rga_releases')
190
210
  when 'zoxide'
191
211
  "#{ConfigLoader.message('health.install_apt')} zoxide (Ubuntu/Debian) or check your package manager"
212
+ when 'bat'
213
+ "#{ConfigLoader.message('health.install_apt')} bat (Ubuntu/Debian) or check your package manager # optional: syntax highlight"
192
214
  end
193
215
  else
194
216
  ConfigLoader.message('health.install_guide')
@@ -31,13 +31,17 @@ module Rufio
31
31
  end
32
32
 
33
33
  # Phase1: Only process dirty rows (rows that have changed)
34
+ # 全dirty rowsの出力を1つのバッファに積んでから単一の write() で書き出す。
35
+ # STDOUT sync=true 環境で print を行ごとに呼ぶと各行で即座にフラッシュされ
36
+ # 中間状態が表示されてちらつきが発生するため、アトミックな更新を保証する。
37
+ buf = String.new("")
34
38
  rendered_count = 0
35
39
  dirty.each do |y|
36
40
  line = screen.row(y)
37
41
  next if line == @front[y] # Skip if content is actually the same
38
42
 
39
- # Move cursor to line y (1-indexed) and output the line
40
- @output.print "\e[#{y + 1};1H#{line}"
43
+ # Move cursor to line y (1-indexed) and buffer the line
44
+ buf << "\e[#{y + 1};1H#{line}"
41
45
  @front[y] = line
42
46
  rendered_count += 1
43
47
  end
@@ -45,8 +49,11 @@ module Rufio
45
49
  # Phase1: Clear dirty tracking after rendering
46
50
  screen.clear_dirty
47
51
 
48
- # Only flush if we actually rendered something
49
- @output.flush if rendered_count > 0
52
+ # 単一の write() でアトミックに出力し、その後 flush する
53
+ if rendered_count > 0
54
+ @output.write(buf)
55
+ @output.flush
56
+ end
50
57
 
51
58
  true
52
59
  end
data/lib/rufio/screen.rb CHANGED
@@ -114,7 +114,8 @@ module Rufio
114
114
  return " " * @width if y < 0 || y >= @height
115
115
 
116
116
  # Phase1: Pre-allocate string capacity for better performance
117
- result = String.new(capacity: @width * 20)
117
+ # String.new("", capacity: N) inherits UTF-8 encoding from ""; String.new(capacity: N) creates ASCII-8BIT
118
+ result = String.new("", capacity: @width * 20)
118
119
  current_width = 0 # Phase1: Accumulate width from cells (no recalculation)
119
120
 
120
121
  # オーバーレイがある場合はベース + オーバーレイを合成
@@ -281,7 +282,7 @@ module Rufio
281
282
  return char if fg.nil? && bg.nil?
282
283
 
283
284
  # Phase1: String builder with pre-allocated capacity (no array generation)
284
- result = String.new(capacity: 30)
285
+ result = String.new("", capacity: 30)
285
286
  result << fg if fg
286
287
  result << bg if bg
287
288
  result << char
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rufio
4
+ # bat コマンドを使ってファイルのシンタックスハイライトを行うクラス。
5
+ # bat が存在しない場合は available? が false を返し、highlight は [] を返す。
6
+ # mtime ベースのキャッシュを持ち、同じファイルの2回目以降は0msで返す。
7
+ class SyntaxHighlighter
8
+ def initialize
9
+ @bat_available = bat_available?
10
+ @cache = {} # file_path => { mtime: Time, lines: Array<String> }
11
+ @pending = {} # file_path => true(非同期実行中フラグ)
12
+ @mutex = Mutex.new
13
+ end
14
+
15
+ # bat コマンドが利用可能かどうか
16
+ #
17
+ # @return [Boolean]
18
+ def available?
19
+ @bat_available
20
+ end
21
+
22
+ # ファイルをシンタックスハイライトして ANSI 付き行の配列を返す(同期版)。
23
+ # bat が使えない場合、ファイルが存在しない場合は []。
24
+ #
25
+ # @param file_path [String] ハイライト対象のファイルパス
26
+ # @param max_lines [Integer] 取得する最大行数
27
+ # @return [Array<String>] ANSI エスケープコード付きの行配列
28
+ def highlight(file_path, max_lines: 50)
29
+ return [] unless @bat_available
30
+ return [] unless File.exist?(file_path)
31
+
32
+ mtime = File.mtime(file_path)
33
+ @mutex.synchronize do
34
+ cache = @cache[file_path]
35
+ return cache[:lines] if cache && cache[:mtime] == mtime
36
+ end
37
+
38
+ lines = run_bat(file_path, max_lines)
39
+ @mutex.synchronize { @cache[file_path] = { mtime: mtime, lines: lines } }
40
+ lines
41
+ rescue => _e
42
+ []
43
+ end
44
+
45
+ # ファイルをバックグラウンドスレッドでシンタックスハイライトする(非同期版)。
46
+ # メインループをブロックせず、bat 完了時にコールバックを呼ぶ。
47
+ # キャッシュヒット時はコールバックを即時(同期的に)呼ぶ。
48
+ # 同一ファイルへの重複呼び出しは無視する(ペンディングガード)。
49
+ #
50
+ # @param file_path [String] ハイライト対象のファイルパス
51
+ # @param max_lines [Integer] 取得する最大行数
52
+ # @yieldparam lines [Array<String>] ANSI付き行配列(エラー時は [])
53
+ def highlight_async(file_path, max_lines: 50, &callback)
54
+ return unless @bat_available
55
+ return unless File.exist?(file_path)
56
+
57
+ mtime = File.mtime(file_path)
58
+
59
+ @mutex.synchronize do
60
+ # キャッシュヒット → 即時コールバック
61
+ cache = @cache[file_path]
62
+ if cache && cache[:mtime] == mtime
63
+ callback&.call(cache[:lines])
64
+ return
65
+ end
66
+
67
+ # 既にペンディング中 → 重複スレッドを立てない
68
+ return if @pending[file_path]
69
+
70
+ @pending[file_path] = true
71
+ end
72
+
73
+ Thread.new do
74
+ begin
75
+ lines = run_bat(file_path, max_lines)
76
+ @mutex.synchronize do
77
+ @cache[file_path] = { mtime: mtime, lines: lines }
78
+ @pending.delete(file_path)
79
+ end
80
+ callback&.call(lines)
81
+ rescue => _e
82
+ @mutex.synchronize { @pending.delete(file_path) }
83
+ callback&.call([])
84
+ end
85
+ end
86
+
87
+ nil
88
+ end
89
+
90
+ private
91
+
92
+ def bat_available?
93
+ system('which bat > /dev/null 2>&1')
94
+ end
95
+
96
+ def run_bat(file_path, max_lines)
97
+ output = IO.popen(
98
+ ['bat', '--color=always', '--plain', '--line-range', "1:#{max_lines}", file_path],
99
+ err: File::NULL
100
+ ) { |io| io.read }
101
+
102
+ return [] unless output
103
+
104
+ output
105
+ .encode('UTF-8', invalid: :replace, undef: :replace, replace: '?')
106
+ .split("\n")
107
+ rescue => _e
108
+ []
109
+ end
110
+ end
111
+ end
@@ -66,6 +66,11 @@ module Rufio
66
66
  @preview_cache = {}
67
67
  @last_preview_path = nil
68
68
 
69
+ # シンタックスハイライター(bat が利用可能な場合のみ動作)
70
+ @syntax_highlighter = SyntaxHighlighter.new
71
+ # 非同期ハイライト完了フラグ(Thread → メインループへの通知)
72
+ @highlight_updated = false
73
+
69
74
  # Footer cache (bookmark list)
70
75
  @cached_bookmarks = nil
71
76
  @cached_bookmark_time = nil
@@ -298,6 +303,12 @@ module Rufio
298
303
  needs_redraw = true
299
304
  end
300
305
 
306
+ # 非同期シンタックスハイライト完了チェック(バックグラウンドスレッドからの通知)
307
+ if @highlight_updated
308
+ @highlight_updated = false
309
+ needs_redraw = true
310
+ end
311
+
301
312
  # DRAW & RENDER phase - 変更があった場合のみ描画
302
313
  if needs_redraw
303
314
  # Screenバッファに描画(clearは呼ばない。必要な部分だけ更新)
@@ -829,14 +840,19 @@ module Rufio
829
840
  # プレビューコンテンツをキャッシュから取得(毎フレームのファイルI/Oを回避)
830
841
  preview_content = nil
831
842
  wrapped_lines = nil
843
+ highlighted_wrapped_lines = nil
832
844
 
833
845
  if selected_entry && selected_entry[:type] == 'file'
834
846
  # キャッシュチェック: 選択ファイルが変わった場合のみプレビューを更新
835
847
  if @last_preview_path != selected_entry[:path]
836
- preview_content = get_preview_content(selected_entry)
848
+ full_preview = @file_preview.preview_file(selected_entry[:path])
849
+ preview_content = extract_preview_lines(full_preview)
837
850
  @preview_cache[selected_entry[:path]] = {
838
851
  content: preview_content,
839
- wrapped: {} # 幅ごとにキャッシュ
852
+ preview_data: full_preview,
853
+ highlighted: nil, # nil = 未取得
854
+ wrapped: {},
855
+ highlighted_wrapped: {}
840
856
  }
841
857
  @last_preview_path = selected_entry[:path]
842
858
  else
@@ -845,8 +861,49 @@ module Rufio
845
861
  preview_content = cache_entry[:content] if cache_entry
846
862
  end
847
863
 
848
- # 折り返し処理もキャッシュ
849
- if preview_content && safe_width > 0
864
+ # bat が利用可能な場合はシンタックスハイライトを取得(非同期)
865
+ if @syntax_highlighter&.available? && preview_content
866
+ cache_entry = @preview_cache[selected_entry[:path]]
867
+ if cache_entry
868
+ preview_data = cache_entry[:preview_data]
869
+ if preview_data && preview_data[:type] == 'code' && preview_data[:encoding] == 'UTF-8'
870
+ # ハイライト行を未取得なら非同期で bat を呼び出す
871
+ # nil = 未リクエスト、false = リクエスト済み(結果待ち)、Array = 取得済み
872
+ if cache_entry[:highlighted].nil?
873
+ # 即座に false をセットしてペンディング状態にする(重複リクエスト防止)
874
+ cache_entry[:highlighted] = false
875
+ file_path = selected_entry[:path]
876
+ @syntax_highlighter.highlight_async(file_path) do |lines|
877
+ # バックグラウンドスレッドからキャッシュを更新
878
+ if (ce = @preview_cache[file_path])
879
+ ce[:highlighted] = lines
880
+ ce[:highlighted_wrapped] = {} # 折り返しキャッシュをクリア
881
+ end
882
+ @highlight_updated = true # メインループに再描画を通知
883
+ end
884
+ # このフレームはプレーンテキストで表示(次フレームでハイライト表示)
885
+ end
886
+
887
+ highlighted = cache_entry[:highlighted]
888
+ if highlighted.is_a?(Array) && !highlighted.empty? && safe_width > 0
889
+ if cache_entry[:highlighted_wrapped][safe_width]
890
+ highlighted_wrapped_lines = cache_entry[:highlighted_wrapped][safe_width]
891
+ else
892
+ # 各ハイライト行をトークン化して折り返す
893
+ hl_wrapped = highlighted.flat_map do |hl_line|
894
+ tokens = AnsiLineParser.parse(hl_line)
895
+ tokens.empty? ? [[]] : AnsiLineParser.wrap(tokens, safe_width - 1)
896
+ end
897
+ cache_entry[:highlighted_wrapped][safe_width] = hl_wrapped
898
+ highlighted_wrapped_lines = hl_wrapped
899
+ end
900
+ end
901
+ end
902
+ end
903
+ end
904
+
905
+ # プレーンテキストの折り返し(ハイライトなしのフォールバック)
906
+ if preview_content && safe_width > 0 && highlighted_wrapped_lines.nil?
850
907
  cache_entry = @preview_cache[selected_entry[:path]]
851
908
  if cache_entry && cache_entry[:wrapped][safe_width]
852
909
  wrapped_lines = cache_entry[:wrapped][safe_width]
@@ -857,48 +914,55 @@ module Rufio
857
914
  end
858
915
  end
859
916
 
917
+ content_x = cursor_position + 1
918
+
860
919
  (0...height).each do |i|
861
920
  line_num = i + CONTENT_START_LINE
862
921
 
863
922
  # 区切り線
864
923
  screen.put(cursor_position, line_num, '│')
865
924
 
866
- content_to_print = ''
925
+ next if safe_width <= 0
867
926
 
868
927
  if selected_entry && i == 0
869
928
  # プレビューヘッダー
870
929
  header = " #{selected_entry[:name]} "
871
- if @keybind_handler&.preview_focused?
872
- header += "[PREVIEW MODE]"
873
- end
874
- content_to_print = header
875
- elsif wrapped_lines && i >= 2
876
- # ファイルプレビュー(折り返し対応)
930
+ header += "[PREVIEW MODE]" if @keybind_handler&.preview_focused?
931
+ header = TextUtils.truncate_to_width(header, safe_width) if TextUtils.display_width(header) > safe_width
932
+ remaining_space = safe_width - TextUtils.display_width(header)
933
+ header += ' ' * remaining_space if remaining_space > 0
934
+ screen.put_string(content_x, line_num, header)
935
+
936
+ elsif i >= 2 && highlighted_wrapped_lines
937
+ # シンタックスハイライト付きコンテンツ
877
938
  scroll_offset = @keybind_handler&.preview_scroll_offset || 0
878
939
  display_line_index = i - 2 + scroll_offset
879
940
 
880
- if display_line_index < wrapped_lines.length
881
- line = wrapped_lines[display_line_index] || ''
882
- content_to_print = " #{line}"
941
+ if display_line_index < highlighted_wrapped_lines.length
942
+ draw_highlighted_line_to_buffer(screen, content_x, line_num,
943
+ highlighted_wrapped_lines[display_line_index], safe_width)
883
944
  else
884
- content_to_print = ' '
945
+ screen.put_string(content_x, line_num, ' ' * safe_width)
885
946
  end
886
- else
887
- content_to_print = ' '
888
- end
889
947
 
890
- # safe_widthを超えないよう切り詰め
891
- next if safe_width <= 0
892
-
893
- if TextUtils.display_width(content_to_print) > safe_width
894
- content_to_print = TextUtils.truncate_to_width(content_to_print, safe_width)
895
- end
948
+ elsif i >= 2 && wrapped_lines
949
+ # プレーンテキストコンテンツ
950
+ scroll_offset = @keybind_handler&.preview_scroll_offset || 0
951
+ display_line_index = i - 2 + scroll_offset
896
952
 
897
- # パディングを追加
898
- remaining_space = safe_width - TextUtils.display_width(content_to_print)
899
- content_to_print += ' ' * remaining_space if remaining_space > 0
953
+ content_to_print = if display_line_index < wrapped_lines.length
954
+ " #{wrapped_lines[display_line_index] || ''}"
955
+ else
956
+ ' '
957
+ end
958
+ content_to_print = TextUtils.truncate_to_width(content_to_print, safe_width) if TextUtils.display_width(content_to_print) > safe_width
959
+ remaining_space = safe_width - TextUtils.display_width(content_to_print)
960
+ content_to_print += ' ' * remaining_space if remaining_space > 0
961
+ screen.put_string(content_x, line_num, content_to_print)
900
962
 
901
- screen.put_string(cursor_position + 1, line_num, content_to_print)
963
+ else
964
+ screen.put_string(content_x, line_num, ' ' * safe_width)
965
+ end
902
966
  end
903
967
  end
904
968
 
@@ -994,6 +1058,13 @@ module Rufio
994
1058
  return [] unless entry && entry[:type] == 'file'
995
1059
 
996
1060
  preview = @file_preview.preview_file(entry[:path])
1061
+ extract_preview_lines(preview)
1062
+ rescue StandardError
1063
+ ["(#{ConfigLoader.message('file.preview_error')})"]
1064
+ end
1065
+
1066
+ # FilePreview の結果ハッシュからプレーンテキスト行を抽出する
1067
+ def extract_preview_lines(preview)
997
1068
  case preview[:type]
998
1069
  when 'text', 'code'
999
1070
  preview[:lines]
@@ -1008,6 +1079,36 @@ module Rufio
1008
1079
  ["(#{ConfigLoader.message('file.preview_error')})"]
1009
1080
  end
1010
1081
 
1082
+ # ハイライト済みトークン列を1行分 Screen バッファに描画する
1083
+ # 先頭に1スペースを追加し、残りをスペースで埋める
1084
+ def draw_highlighted_line_to_buffer(screen, x, y, tokens, max_width)
1085
+ current_x = x
1086
+ max_x = x + max_width
1087
+
1088
+ # 先頭スペース
1089
+ if current_x < max_x
1090
+ screen.put(current_x, y, ' ')
1091
+ current_x += 1
1092
+ end
1093
+
1094
+ # トークンを描画
1095
+ tokens&.each do |token|
1096
+ break if current_x >= max_x
1097
+ token[:text].each_char do |char|
1098
+ char_w = TextUtils.char_width(char)
1099
+ break if current_x + char_w > max_x
1100
+ screen.put(current_x, y, char, fg: token[:fg])
1101
+ current_x += char_w
1102
+ end
1103
+ end
1104
+
1105
+ # 残りをスペースで埋める
1106
+ while current_x < max_x
1107
+ screen.put(current_x, y, ' ')
1108
+ current_x += 1
1109
+ end
1110
+ end
1111
+
1011
1112
 
1012
1113
  def get_display_entries
1013
1114
  entries = if @keybind_handler.filter_active?
data/lib/rufio/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rufio
4
- VERSION = '0.71.0'
4
+ VERSION = '0.80.0'
5
5
  end
data/lib/rufio.rb CHANGED
@@ -16,6 +16,8 @@ require_relative 'rufio/dialog_renderer'
16
16
  require_relative 'rufio/text_utils'
17
17
  require_relative 'rufio/logger'
18
18
  require_relative 'rufio/keybind_handler'
19
+ require_relative 'rufio/ansi_line_parser'
20
+ require_relative 'rufio/syntax_highlighter'
19
21
  require_relative 'rufio/file_preview'
20
22
  require_relative 'rufio/terminal_ui'
21
23
  require_relative 'rufio/application'
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: 0.71.0
4
+ version: 0.80.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-02-16 00:00:00.000000000 Z
11
+ date: 2026-02-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: io-console
@@ -137,6 +137,7 @@ files:
137
137
  - docs/CHANGELOG_v0.6.0.md
138
138
  - docs/CHANGELOG_v0.7.0.md
139
139
  - docs/CHANGELOG_v0.8.0.md
140
+ - docs/CHANGELOG_v0.80.0.md
140
141
  - docs/CHANGELOG_v0.9.0.md
141
142
  - examples/bookmarks.yml
142
143
  - examples/config.rb
@@ -145,6 +146,7 @@ files:
145
146
  - info/keybindings.md
146
147
  - info/welcome.md
147
148
  - lib/rufio.rb
149
+ - lib/rufio/ansi_line_parser.rb
148
150
  - lib/rufio/application.rb
149
151
  - lib/rufio/async_scanner_fiber.rb
150
152
  - lib/rufio/async_scanner_promise.rb
@@ -191,6 +193,7 @@ files:
191
193
  - lib/rufio/script_runner.rb
192
194
  - lib/rufio/selection_manager.rb
193
195
  - lib/rufio/shell_command_completion.rb
196
+ - lib/rufio/syntax_highlighter.rb
194
197
  - lib/rufio/tab_mode_manager.rb
195
198
  - lib/rufio/task_status.rb
196
199
  - lib/rufio/terminal_ui.rb