rufio 0.70.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: f7c56fe39c80425ba94128d7e8531584e1d7530d9eb78d323b59d34563df1fe3
4
- data.tar.gz: 659fe182e9aace392e902151caac5a289b969db060f31f7176c2545e22d452c8
3
+ metadata.gz: a5c4fc956ed9bcb90569c8c7fcdd023fec3d31cdf1852f075ba40c9746a654dc
4
+ data.tar.gz: f07ae9f1f0071de307b407e5322f3f2a53c62fb654f023223f7214be79350dc3
5
5
  SHA512:
6
- metadata.gz: 01fe9fe08f1e494eafcfc18aeb1a8b208c6406b60525d81a6b0ebebe3131fa8388f3b86631d7ae2ea15ec02f20eaf21d3daaa01eec6a011174050750b5f51fb4
7
- data.tar.gz: 4920c3320a0df0d87b0af4b410b59722efce3163f53f5098c5ceadcb45f5124b49f4c0774d9e22daef1d293f3417a1fa397977932946a776210f4452bf141ecb
6
+ metadata.gz: '097aed068bd564b961c159504b7396c4d0dec7b9eb4be819d2f83380fc10aa86e698c316b9e95bfb012863790ea53cbe0ee521d3561919cdde61fbafcbb22959'
7
+ data.tar.gz: 6d1d2ce8640debc3fe6fbe282e75a959bb4c7a846f21cde65d0a4cd2a713329c7416591c2d182b05e7bd67b4df9895071cac32dc9ab85940459eed8f19660881
data/CHANGELOG.md CHANGED
@@ -7,6 +7,44 @@ 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
+
36
+ ## [0.71.0] - 2026-02-16
37
+
38
+ ### Added
39
+ - **Script Arguments**: Pass arguments to scripts via command mode (e.g., `@retag.sh v0.70.0`)
40
+ - Supports both ScriptRunner and LocalScriptScanner
41
+ - **Script/Rake Execution Logging**: Log execution results of `@script` and `rake:task` commands to CommandLogger
42
+ - Covers both synchronous and background execution
43
+
44
+ ### Fixed
45
+ - **Selection across directories**: Clear selection state when navigating to a different directory
46
+ - **Help/Log viewer entries**: Exclude `..` entry in help mode and log viewer mode
47
+
10
48
  ## [0.70.0] - 2026-02-14
11
49
 
12
50
  ### Added
@@ -599,6 +637,7 @@ For detailed information, see [CHANGELOG_v0.4.0.md](./docs/CHANGELOG_v0.4.0.md)
599
637
 
600
638
  ### Detailed Release Notes
601
639
 
640
+ - [v0.80.0](./docs/CHANGELOG_v0.80.0.md) - Syntax Highlighting & Rendering Fixes
602
641
  - [v0.31.0](./docs/CHANGELOG_v0.31.0.md) - Experimental Native Scanner Implementation
603
642
  - [v0.30.0](./docs/CHANGELOG_v0.30.0.md) - Help System Overhaul
604
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
@@ -28,7 +28,8 @@ module Rufio
28
28
  @job_manager = job_manager
29
29
  @script_runner = ScriptRunner.new(
30
30
  script_paths: script_paths,
31
- job_manager: job_manager
31
+ job_manager: job_manager,
32
+ command_logger: @background_executor&.command_logger
32
33
  )
33
34
  end
34
35
 
@@ -41,7 +42,8 @@ module Rufio
41
42
  # ScriptRunnerも設定(ScriptPathManagerのパスを使用)
42
43
  @script_runner = ScriptRunner.new(
43
44
  script_paths: @script_path_manager.paths,
44
- job_manager: job_manager
45
+ job_manager: job_manager,
46
+ command_logger: @background_executor&.command_logger
45
47
  )
46
48
  end
47
49
 
@@ -237,22 +239,27 @@ module Rufio
237
239
 
238
240
  # スクリプトを実行する(@プレフィックス用)
239
241
  # ScriptRunner → LocalScriptScanner の順にフォールバック
240
- # @param script_name [String] スクリプト名
242
+ # @param script_input [String] スクリプト名(引数を含む場合あり)
241
243
  # @param working_dir [String, nil] 作業ディレクトリ
242
244
  # @return [String] 実行結果メッセージ
243
- def execute_script(script_name, working_dir)
245
+ def execute_script(script_input, working_dir)
244
246
  working_dir ||= Dir.pwd
245
247
 
248
+ # スクリプト名と引数を分離(例: "retag.sh v0.70.0" → name="retag.sh", args="v0.70.0")
249
+ parts = script_input.split(' ', 2)
250
+ script_name = parts[0]
251
+ script_args = parts[1]
252
+
246
253
  # ScriptRunnerで検索
247
254
  if @script_runner
248
- job = @script_runner.run(script_name, working_dir: working_dir)
255
+ job = @script_runner.run(script_name, working_dir: working_dir, args: script_args)
249
256
  return "🚀 ジョブを開始: #{script_name}" if job
250
257
  end
251
258
 
252
259
  # LocalScriptScannerにフォールバック
253
260
  local_script = @local_script_scanner.find_script(script_name)
254
261
  if local_script
255
- return execute_local_script(local_script, working_dir)
262
+ return execute_local_script(local_script, working_dir, script_args)
256
263
  end
257
264
 
258
265
  # どちらにも見つからない
@@ -266,33 +273,57 @@ module Rufio
266
273
  # ローカルスクリプトを実行する
267
274
  # @param script [Hash] スクリプト情報 { name:, path:, dir: }
268
275
  # @param working_dir [String] 作業ディレクトリ
269
- # @return [String] 実行結果メッセージ
270
- def execute_local_script(script, working_dir)
276
+ # @param args [String, nil] スクリプトに渡す引数
277
+ # @return [String, Hash] 実行結果メッセージ
278
+ def execute_local_script(script, working_dir, args = nil)
279
+ command = build_script_command(script)
280
+ command = "#{command} #{args}" if args && !args.empty?
281
+
271
282
  if @job_manager
272
283
  job = @job_manager.add_job(
273
284
  name: script[:name],
274
285
  path: working_dir,
275
- command: build_script_command(script)
286
+ command: command
276
287
  )
277
288
  job.start
278
289
 
279
290
  Thread.new do
280
- execute_script_in_background(job, script, working_dir)
291
+ execute_script_in_background(job, script, working_dir, command)
281
292
  end
282
293
 
283
294
  "🚀 ジョブを開始: #{script[:name]}"
284
295
  else
285
296
  # 同期実行
286
- command = build_script_command(script)
287
297
  stdout, stderr, status = Open3.capture3(command, chdir: working_dir)
288
- {
298
+ result = {
289
299
  success: status.success?,
290
300
  output: stdout.strip,
291
301
  stderr: stderr.strip
292
302
  }
303
+
304
+ # Logsに記録
305
+ log_execution("@#{script[:name]}", result)
306
+
307
+ result
293
308
  end
294
309
  end
295
310
 
311
+ # 実行結果をCommandLoggerに記録
312
+ # @param command_name [String] コマンド名
313
+ # @param result [Hash] 実行結果 { success:, output:, stderr:, error: }
314
+ def log_execution(command_name, result)
315
+ logger = @background_executor&.command_logger
316
+ return unless logger
317
+
318
+ output = [result[:output], result[:stderr]].compact.reject(&:empty?).join("\n")
319
+ logger.log(
320
+ command_name,
321
+ output,
322
+ success: result[:success],
323
+ error: result[:error]
324
+ )
325
+ end
326
+
296
327
  # スクリプトの実行コマンドを構築
297
328
  # @param script [Hash] スクリプト情報
298
329
  # @return [String] 実行コマンド
@@ -319,8 +350,7 @@ module Rufio
319
350
  end
320
351
 
321
352
  # ローカルスクリプトをバックグラウンドで実行
322
- def execute_script_in_background(job, script, working_dir)
323
- command = build_script_command(script)
353
+ def execute_script_in_background(job, script, working_dir, command)
324
354
  stdout, stderr, status = Open3.capture3(command, chdir: working_dir)
325
355
 
326
356
  job.append_log(stdout) unless stdout.empty?
@@ -332,10 +362,18 @@ module Rufio
332
362
  job.fail(exit_code: status.exitstatus)
333
363
  end
334
364
 
365
+ # Logsに記録
366
+ log_execution("@#{script[:name]}", {
367
+ success: status.success?,
368
+ output: stdout.strip,
369
+ stderr: stderr.strip
370
+ })
371
+
335
372
  @job_manager&.notify_completion(job)
336
373
  rescue StandardError => e
337
374
  job.append_log("Error: #{e.message}")
338
375
  job.fail(exit_code: -1)
376
+ log_execution("@#{script[:name]}", { success: false, output: '', stderr: e.message })
339
377
  @job_manager&.notify_completion(job)
340
378
  end
341
379
 
@@ -368,11 +406,18 @@ module Rufio
368
406
  result[:error] = "コマンドが失敗しました (終了コード: #{status.exitstatus})"
369
407
  end
370
408
 
409
+ # Logsに記録
410
+ log_execution("rake:#{task_name}", result)
411
+
371
412
  result
372
413
  rescue Errno::ENOENT => e
373
- { success: false, error: "rakeが見つかりません: #{e.message}" }
414
+ result = { success: false, error: "rakeが見つかりません: #{e.message}" }
415
+ log_execution("rake:#{task_name}", result)
416
+ result
374
417
  rescue StandardError => e
375
- { success: false, error: "rake実行エラー: #{e.message}" }
418
+ result = { success: false, error: "rake実行エラー: #{e.message}" }
419
+ log_execution("rake:#{task_name}", result)
420
+ result
376
421
  end
377
422
  end
378
423
 
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')
@@ -218,10 +218,15 @@ module Rufio
218
218
  end
219
219
 
220
220
  def get_active_entries
221
- if @filter_manager.filter_active?
222
- @filter_manager.filtered_entries
221
+ entries = if @filter_manager.filter_active?
222
+ @filter_manager.filtered_entries
223
+ else
224
+ @directory_listing&.list_entries || []
225
+ end
226
+ if @in_help_mode || @in_log_viewer_mode
227
+ entries.reject { |e| e[:name] == '..' }
223
228
  else
224
- @directory_listing&.list_entries || []
229
+ entries
225
230
  end
226
231
  end
227
232
 
@@ -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
@@ -11,9 +11,11 @@ module Rufio
11
11
 
12
12
  # @param script_paths [Array<String>] スクリプトを検索するディレクトリのリスト
13
13
  # @param job_manager [JobManager, nil] ジョブマネージャー(nilの場合は同期実行)
14
- def initialize(script_paths:, job_manager: nil)
14
+ # @param command_logger [CommandLogger, nil] コマンドロガー
15
+ def initialize(script_paths:, job_manager: nil, command_logger: nil)
15
16
  @script_paths = script_paths.map { |p| File.expand_path(p) }
16
17
  @job_manager = job_manager
18
+ @command_logger = command_logger
17
19
  @scripts_cache = nil
18
20
  end
19
21
 
@@ -54,15 +56,16 @@ module Rufio
54
56
  # スクリプトをジョブとして実行
55
57
  # @param name [String] スクリプト名
56
58
  # @param working_dir [String] 作業ディレクトリ
59
+ # @param args [String, nil] スクリプトに渡す引数
57
60
  # @param selected_file [String, nil] 選択中のファイル
58
61
  # @param selected_dir [String, nil] 選択中のディレクトリ
59
62
  # @return [TaskStatus, nil] 作成されたジョブ、またはスクリプトが見つからない場合nil
60
- def run(name, working_dir:, selected_file: nil, selected_dir: nil)
63
+ def run(name, working_dir:, args: nil, selected_file: nil, selected_dir: nil)
61
64
  script = find_script(name)
62
65
  return nil unless script
63
66
 
64
67
  env = build_environment(working_dir, selected_file, selected_dir)
65
- execute_script(script, working_dir, env)
68
+ execute_script(script, working_dir, env, args)
66
69
  end
67
70
 
68
71
  # キャッシュをクリア
@@ -130,9 +133,11 @@ module Rufio
130
133
  # @param script [Hash] スクリプト情報
131
134
  # @param working_dir [String] 作業ディレクトリ
132
135
  # @param env [Hash] 環境変数
136
+ # @param args [String, nil] スクリプトに渡す引数
133
137
  # @return [TaskStatus] 作成されたジョブ
134
- def execute_script(script, working_dir, env = {})
138
+ def execute_script(script, working_dir, env = {}, args = nil)
135
139
  command = build_command(script)
140
+ command = "#{command} #{args}" if args && !args.empty?
136
141
 
137
142
  if @job_manager
138
143
  # ジョブマネージャーにジョブを追加
@@ -205,12 +210,24 @@ module Rufio
205
210
  job.fail(exit_code: status.exitstatus)
206
211
  end
207
212
 
213
+ # CommandLoggerに記録
214
+ log_to_command_logger(job.name, stdout, stderr, status.success?)
215
+
208
216
  # 通知を送信
209
217
  @job_manager&.notify_completion(job)
210
218
  rescue StandardError => e
211
219
  job.append_log("Error: #{e.message}")
212
220
  job.fail(exit_code: -1)
221
+ log_to_command_logger(job.name, '', e.message, false)
213
222
  @job_manager&.notify_completion(job)
214
223
  end
224
+
225
+ # CommandLoggerに実行結果を記録
226
+ def log_to_command_logger(name, stdout, stderr, success)
227
+ return unless @command_logger
228
+
229
+ output = [stdout, stderr].compact.map(&:strip).reject(&:empty?).join("\n")
230
+ @command_logger.log("@#{name}", output, success: success)
231
+ end
215
232
  end
216
233
  end
@@ -17,6 +17,12 @@ module Rufio
17
17
  def toggle_selection(entry, current_directory = nil)
18
18
  return false unless entry
19
19
 
20
+ # 異なるディレクトリで選択した場合、古い選択をクリアしてリセット
21
+ if current_directory && @source_directory && current_directory != @source_directory
22
+ @selected_items.clear
23
+ @source_directory = nil
24
+ end
25
+
20
26
  if @selected_items.include?(entry[:name])
21
27
  @selected_items.delete(entry[:name])
22
28
  # Clear source_directory if no items are selected
@@ -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
@@ -216,6 +221,7 @@ module Rufio
216
221
  last_frame_time = Time.now
217
222
  current_fps = 0.0
218
223
  last_fps_update = Time.now
224
+ @last_displayed_fps = 0.0
219
225
 
220
226
  # 再描画フラグ
221
227
  needs_redraw = false
@@ -237,8 +243,11 @@ module Rufio
237
243
  last_fps_update = start
238
244
  end
239
245
 
240
- # test_modeでは毎フレーム描画してFPS計測の精度を上げる
241
- needs_redraw = true
246
+ # FPS表示の更新タイミングで再描画(1秒ごと)
247
+ if current_fps != @last_displayed_fps
248
+ @last_displayed_fps = current_fps
249
+ needs_redraw = true
250
+ end
242
251
  end
243
252
 
244
253
  # UPDATE phase - ノンブロッキング入力処理
@@ -294,6 +303,12 @@ module Rufio
294
303
  needs_redraw = true
295
304
  end
296
305
 
306
+ # 非同期シンタックスハイライト完了チェック(バックグラウンドスレッドからの通知)
307
+ if @highlight_updated
308
+ @highlight_updated = false
309
+ needs_redraw = true
310
+ end
311
+
297
312
  # DRAW & RENDER phase - 変更があった場合のみ描画
298
313
  if needs_redraw
299
314
  # Screenバッファに描画(clearは呼ばない。必要な部分だけ更新)
@@ -825,14 +840,19 @@ module Rufio
825
840
  # プレビューコンテンツをキャッシュから取得(毎フレームのファイルI/Oを回避)
826
841
  preview_content = nil
827
842
  wrapped_lines = nil
843
+ highlighted_wrapped_lines = nil
828
844
 
829
845
  if selected_entry && selected_entry[:type] == 'file'
830
846
  # キャッシュチェック: 選択ファイルが変わった場合のみプレビューを更新
831
847
  if @last_preview_path != selected_entry[:path]
832
- 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)
833
850
  @preview_cache[selected_entry[:path]] = {
834
851
  content: preview_content,
835
- wrapped: {} # 幅ごとにキャッシュ
852
+ preview_data: full_preview,
853
+ highlighted: nil, # nil = 未取得
854
+ wrapped: {},
855
+ highlighted_wrapped: {}
836
856
  }
837
857
  @last_preview_path = selected_entry[:path]
838
858
  else
@@ -841,8 +861,49 @@ module Rufio
841
861
  preview_content = cache_entry[:content] if cache_entry
842
862
  end
843
863
 
844
- # 折り返し処理もキャッシュ
845
- 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?
846
907
  cache_entry = @preview_cache[selected_entry[:path]]
847
908
  if cache_entry && cache_entry[:wrapped][safe_width]
848
909
  wrapped_lines = cache_entry[:wrapped][safe_width]
@@ -853,48 +914,55 @@ module Rufio
853
914
  end
854
915
  end
855
916
 
917
+ content_x = cursor_position + 1
918
+
856
919
  (0...height).each do |i|
857
920
  line_num = i + CONTENT_START_LINE
858
921
 
859
922
  # 区切り線
860
923
  screen.put(cursor_position, line_num, '│')
861
924
 
862
- content_to_print = ''
925
+ next if safe_width <= 0
863
926
 
864
927
  if selected_entry && i == 0
865
928
  # プレビューヘッダー
866
929
  header = " #{selected_entry[:name]} "
867
- if @keybind_handler&.preview_focused?
868
- header += "[PREVIEW MODE]"
869
- end
870
- content_to_print = header
871
- elsif wrapped_lines && i >= 2
872
- # ファイルプレビュー(折り返し対応)
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
+ # シンタックスハイライト付きコンテンツ
873
938
  scroll_offset = @keybind_handler&.preview_scroll_offset || 0
874
939
  display_line_index = i - 2 + scroll_offset
875
940
 
876
- if display_line_index < wrapped_lines.length
877
- line = wrapped_lines[display_line_index] || ''
878
- 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)
879
944
  else
880
- content_to_print = ' '
945
+ screen.put_string(content_x, line_num, ' ' * safe_width)
881
946
  end
882
- else
883
- content_to_print = ' '
884
- end
885
-
886
- # safe_widthを超えないよう切り詰め
887
- next if safe_width <= 0
888
947
 
889
- if TextUtils.display_width(content_to_print) > safe_width
890
- content_to_print = TextUtils.truncate_to_width(content_to_print, safe_width)
891
- 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
892
952
 
893
- # パディングを追加
894
- remaining_space = safe_width - TextUtils.display_width(content_to_print)
895
- 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)
896
962
 
897
- 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
898
966
  end
899
967
  end
900
968
 
@@ -990,6 +1058,13 @@ module Rufio
990
1058
  return [] unless entry && entry[:type] == 'file'
991
1059
 
992
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)
993
1068
  case preview[:type]
994
1069
  when 'text', 'code'
995
1070
  preview[:lines]
@@ -1004,6 +1079,36 @@ module Rufio
1004
1079
  ["(#{ConfigLoader.message('file.preview_error')})"]
1005
1080
  end
1006
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
+
1007
1112
 
1008
1113
  def get_display_entries
1009
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.70.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.70.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-15 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