rufio 1.1.0 → 1.4.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.
Files changed (80) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -0
  3. data/README.md +26 -297
  4. data/README_ja.md +24 -261
  5. data/Rakefile +14 -1
  6. data/bin/rufio +31 -2
  7. data/docs/fast_zip_design_ja.md +167 -0
  8. data/docs/guide.md +410 -0
  9. data/docs/guide_ja.md +410 -0
  10. data/info/help.md +54 -17
  11. data/info/keybindings.md +50 -22
  12. data/info/welcome.md +14 -5
  13. data/lib/rufio/application.rb +12 -1
  14. data/lib/rufio/archive_controller.rb +211 -0
  15. data/lib/rufio/background_command_executor.rb +9 -0
  16. data/lib/rufio/background_status.rb +49 -0
  17. data/lib/rufio/bookmark_controller.rb +38 -121
  18. data/lib/rufio/bookmark_manager.rb +77 -331
  19. data/lib/rufio/command_completion.rb +28 -2
  20. data/lib/rufio/command_history.rb +5 -2
  21. data/lib/rufio/command_mode.rb +60 -134
  22. data/lib/rufio/command_mode_ui.rb +4 -42
  23. data/lib/rufio/config.rb +88 -136
  24. data/lib/rufio/config_loader.rb +4 -11
  25. data/lib/rufio/dialog_renderer.rb +90 -152
  26. data/lib/rufio/dialog_support.rb +69 -0
  27. data/lib/rufio/document_text_extractor.rb +167 -0
  28. data/lib/rufio/file_operation_controller.rb +49 -222
  29. data/lib/rufio/file_operations.rb +28 -6
  30. data/lib/rufio/file_preview.rb +46 -2
  31. data/lib/rufio/global_search.rb +125 -13
  32. data/lib/rufio/help_menu.rb +19 -7
  33. data/lib/rufio/interpreter_resolver.rb +6 -11
  34. data/lib/rufio/job_manager.rb +2 -5
  35. data/lib/rufio/key_input.rb +77 -0
  36. data/lib/rufio/keybind_handler.rb +83 -116
  37. data/lib/rufio/local_script_scanner.rb +6 -18
  38. data/lib/rufio/native/aarch64-linux-gnu/librufio_zig.so +0 -0
  39. data/lib/rufio/native/aarch64-linux-musl/librufio_zig.so +0 -0
  40. data/lib/rufio/native/aarch64-macos/librufio_zig.dylib +0 -0
  41. data/lib/rufio/native/aarch64-windows/rufio_zig.dll +0 -0
  42. data/lib/rufio/native/x86_64-linux-gnu/librufio_zig.so +0 -0
  43. data/lib/rufio/native/x86_64-linux-musl/librufio_zig.so +0 -0
  44. data/lib/rufio/native/x86_64-macos/librufio_zig.dylib +0 -0
  45. data/lib/rufio/native/x86_64-windows/rufio_zig.dll +0 -0
  46. data/lib/rufio/native_content_search.rb +153 -0
  47. data/lib/rufio/native_scanner.rb +13 -68
  48. data/lib/rufio/native_scanner_zig.rb +106 -56
  49. data/lib/rufio/native_zip.rb +121 -0
  50. data/lib/rufio/navigation_controller.rb +20 -26
  51. data/lib/rufio/path_jump.rb +55 -0
  52. data/lib/rufio/screen.rb +2 -12
  53. data/lib/rufio/script_executor.rb +6 -73
  54. data/lib/rufio/script_file.rb +44 -0
  55. data/lib/rufio/script_path_manager.rb +9 -43
  56. data/lib/rufio/script_runner.rb +13 -50
  57. data/lib/rufio/search_controller.rb +1 -1
  58. data/lib/rufio/shell_command_completion.rb +1 -5
  59. data/lib/rufio/shell_init.rb +90 -0
  60. data/lib/rufio/task_status.rb +5 -1
  61. data/lib/rufio/terminal_ui.rb +84 -298
  62. data/lib/rufio/text_utils.rb +26 -3
  63. data/lib/rufio/ui_renderer.rb +73 -45
  64. data/lib/rufio/version.rb +1 -1
  65. data/lib/rufio/zip_archive.rb +314 -0
  66. data/lib/rufio/zip_extractor.rb +346 -0
  67. data/lib/rufio/zip_writer.rb +268 -0
  68. data/lib/rufio/zoxide_integration.rb +5 -43
  69. data/lib/rufio.rb +6 -0
  70. data/lib_zig/rufio_native/Makefile +54 -17
  71. data/lib_zig/rufio_native/bench.sh +36 -0
  72. data/lib_zig/rufio_native/build.zig +32 -35
  73. data/lib_zig/rufio_native/src/cli.zig +99 -0
  74. data/lib_zig/rufio_native/src/main.zig +187 -83
  75. data/lib_zig/rufio_native/src/os.zig +889 -0
  76. data/lib_zig/rufio_native/src/search.zig +919 -0
  77. data/lib_zig/rufio_native/src/search_test.zig +88 -0
  78. data/lib_zig/rufio_native/src/zip.zig +482 -0
  79. metadata +128 -3
  80. data/lib/rufio/native/rufio_zig.bundle +0 -0
data/info/keybindings.md CHANGED
@@ -9,22 +9,24 @@ Complete reference of all keyboard shortcuts in **rufio**.
9
9
  | `j` / `Down` | Move down |
10
10
  | `k` / `Up` | Move up |
11
11
  | `h` / `Left` | Go to parent directory |
12
- | `l` / `Right` / `Enter` | Enter directory or open file |
12
+ | `l` / `Right` / `Enter` | Enter directory |
13
13
  | `g` | Jump to top of list |
14
- | `G` | Jump to bottom of list |
14
+ | `}` | Jump to bottom of list |
15
15
 
16
16
  ## File Operations
17
17
 
18
18
  | Key | Action |
19
19
  |-----|--------|
20
20
  | `o` | Open file with external editor |
21
- | `e` | Open in file explorer (system default) |
21
+ | `e` | Open current directory in file explorer |
22
22
  | `R` | Refresh directory contents |
23
23
  | `r` | Rename file or directory |
24
24
  | `d` | Delete file or directory (with confirmation) |
25
25
  | `a` | Create new file |
26
26
  | `A` | Create new directory |
27
27
 
28
+ `.zip` previews list the archive entries (size, method, modified time, name) without extracting them.
29
+
28
30
  ## File Selection
29
31
 
30
32
  | Key | Action |
@@ -33,6 +35,9 @@ Complete reference of all keyboard shortcuts in **rufio**.
33
35
  | `m` | Move selected items to current directory |
34
36
  | `c` | Copy selected items to current directory |
35
37
  | `x` | Delete selected items |
38
+ | `z` | Extract selected `.zip` files (each into `<name>/`) / compress selected items into one `.zip` |
39
+
40
+ ZIP extraction and compression run as background jobs. Check progress in Jobs mode (`J`).
36
41
 
37
42
  ## Filtering & Search
38
43
 
@@ -40,7 +45,9 @@ Complete reference of all keyboard shortcuts in **rufio**.
40
45
  |-----|--------|
41
46
  | `f` | Enter filter mode (type to filter) |
42
47
  | `s` | Search file names with fzf |
48
+ | `/` | Search file names with fzf (same as `s`) |
43
49
  | `F` | Search file contents with rga |
50
+ | `G` | Global search: file names or contents across all bookmarks |
44
51
 
45
52
  ## Bookmarks & Navigation
46
53
 
@@ -50,30 +57,33 @@ Complete reference of all keyboard shortcuts in **rufio**.
50
57
  | `B` | Bookmark menu (view/add/remove/script paths) |
51
58
  | `0` | Jump to startup directory |
52
59
  | `1-9` | Jump to bookmark 1-9 |
53
- | `z` | Navigate using zoxide history |
60
+ | `Tab` | Cycle to next bookmark (Files mode) |
61
+ | `Shift+Tab` | Cycle to previous bookmark (Files mode) |
62
+ | `z` | Navigate using zoxide history (when nothing is selected) |
54
63
 
55
- ## Command Mode & Tool Runtime
64
+ ## Modes & Tool Runtime
56
65
 
57
66
  | Key | Action |
58
67
  |-----|--------|
59
68
  | `:` | Enter command mode |
69
+ | `G` | Global search mode |
70
+ | `C` | Config mode: open `~/.config/rufio/` |
60
71
  | `J` | Job mode (view background jobs) |
61
72
  | `L` | View execution logs |
62
-
63
- ## Mode Switching
64
-
65
- | Key | Action |
66
- |-----|--------|
67
- | `Tab` | Switch mode: Files -> Logs -> Jobs -> Help |
68
- | `Shift+Tab` | Switch mode (reverse direction) |
69
- | `?` | Enter help mode |
70
- | `ESC` | Exit current mode (help/filter/command) |
71
- | `q` | Quit rufio |
73
+ | `?` | Open the floating help menu |
74
+ | `q` | Quit rufio (with confirmation) |
75
+ | `T` | Quit rufio and cd the shell to the current directory (requires `rufio --shell-init`) |
72
76
 
73
77
  ---
74
78
 
75
79
  ## Mode-Specific Keys
76
80
 
81
+ ### Preview
82
+ - **P**: Open fullscreen file preview (hides the file list)
83
+ - **j/k**: Scroll preview
84
+ - **Ctrl+D/U**: Page down/up
85
+ - **ESC**: Back to the file list
86
+
77
87
  ### Filter Mode
78
88
  - **Any character**: Add to filter query
79
89
  - **Backspace**: Remove last character
@@ -82,7 +92,7 @@ Complete reference of all keyboard shortcuts in **rufio**.
82
92
 
83
93
  ### Command Mode
84
94
  - **Any character**: Add to command input
85
- - **Tab**: Auto-complete (scripts, rake tasks, commands)
95
+ - **Tab**: Auto-complete (scripts, rake tasks, commands, paths)
86
96
  - **Up/Down**: Navigate command history
87
97
  - **Enter**: Execute command
88
98
  - **Backspace**: Remove last character
@@ -92,27 +102,45 @@ Complete reference of all keyboard shortcuts in **rufio**.
92
102
  - `@script` - Execute a registered or local script
93
103
  - `rake:task` - Execute a Rakefile task
94
104
  - `!command` - Execute a shell command
105
+ - `cd <path>` - Go to a directory (paste a path; a file path selects the file, `cd -` goes back, `Tab` completes directories)
95
106
  - `command` - Execute a built-in or plugin command
96
107
 
97
- ### Help Mode
98
- - **j/k/h/l**: Navigate help files
99
- - **ESC**: Exit help mode and return to previous directory
108
+ ### Global Search (G)
109
+ - **Type**: Filter results (literal, case insensitive)
110
+ - **Tab**: Switch between file names and content
111
+ - **Up/Down** or **Ctrl+J/K or Ctrl+N/P**: Select a result
112
+ - **Enter**: Go to the selected file in Files mode
113
+ - **Ctrl+U**: Clear the query
114
+ - **ESC**: Return to the previous mode
115
+ - Content search also reads `.pdf`, `.xlsx` and `.pptx`
100
116
 
101
- ### Job Mode
117
+ ### Config Mode (C)
118
+ - Same navigation, preview and file operations as Files mode
119
+ - Leaving `~/.config/rufio/` returns to Files mode
120
+
121
+ ### Job Mode (J)
102
122
  - **j/k**: Navigate through jobs
123
+ - **g/G**: First / last job
124
+ - **Space**: View job log
125
+ - **`x`: Cancel** the selected job (including ZIP jobs)
103
126
  - **ESC**: Exit job mode
104
127
 
128
+ ### Help Menu (?)
129
+ - **1/2/3** or **Tab/Shift+Tab**: Switch section (Controls / Modes / Scripts)
130
+ - **j/k**: Scroll | **Ctrl+D/U**: Page down/up | **g/G**: Top / bottom
131
+ - **ESC**, **q** or **?**: Close and keep your current mode and directory
132
+
105
133
  ---
106
134
 
107
135
  ## Tips
108
136
 
109
137
  - **Vim users**: Navigation keys (`hjkl`) work exactly like Vim
110
138
  - **Selection**: Use `Space` to mark multiple files, then operate on all at once
111
- - **Quick access**: Number keys `1-9` provide instant access to bookmarks
139
+ - **Quick access**: Number keys `1-9` and `Tab` provide instant access to bookmarks
112
140
  - **Filtering**: Press `f` and start typing for real-time filtering
113
141
  - **Rake tasks**: Type `:rake:` then `Tab` to see available tasks
114
142
  - **Local scripts**: Scripts in the current directory are auto-detected with `@` prefix
115
143
 
116
144
  ---
117
145
 
118
- *Press `?` anytime to return to help mode*
146
+ *Press `?` anytime to open the help menu*
data/info/welcome.md CHANGED
@@ -10,6 +10,8 @@ A TUI file manager that executes and coordinates tools and scripts from files.
10
10
  - **Vim-like Keybindings** - Navigate with `j/k/h/l` keys
11
11
  - **Real-time Filtering** - Press `f` to filter files instantly
12
12
  - **Advanced Search** - Use `fzf` for file names and `rga` for content search
13
+ - **Global Search** - Press `G` to search names or contents (including PDF/Excel/PowerPoint) across all bookmarks
14
+ - **ZIP Support** - Preview `.zip` contents, extract or compress selected items with `z`
13
15
  - **Bookmark Support** - Quick access to favorite directories
14
16
  - **Job Management** - Run commands in the background
15
17
  - **Plugin System** - Extend functionality with custom plugins
@@ -24,12 +26,18 @@ A TUI file manager that executes and coordinates tools and scripts from files.
24
26
  | `s` | Search with fzf |
25
27
  | `b` | Add bookmark |
26
28
  | `B` | Bookmark menu |
27
- | `z` | zoxide history |
29
+ | `Tab` | Next bookmark |
30
+ | `z` | zoxide history / extract or compress selected items |
31
+ | `G` | Global search across bookmarks |
32
+ | `C` | Config mode |
28
33
  | `:` | Command mode |
29
34
  | `J` | Job mode |
30
- | `?` | Help mode |
31
- | `Tab` | Switch mode (Files / Logs / Jobs / Help) |
35
+ | `L` | Execution logs |
36
+ | `?` | Help menu |
32
37
  | `q` | Quit |
38
+ | `T` | Quit and cd to the current directory |
39
+
40
+ In G mode, use `Ctrl+J` / `Ctrl+K` to select the next / previous result and `Enter` to open it.
33
41
 
34
42
  ## Command Mode
35
43
 
@@ -38,12 +46,13 @@ Press `:` to enter command mode:
38
46
  - `@script.sh` - Execute a script
39
47
  - `rake:test` - Execute a rake task
40
48
  - `!git status` - Run a shell command
49
+ - `cd ~/projects` - Go to a pasted path
41
50
  - `Tab` - Auto-complete commands
42
51
 
43
52
  ## Getting Help
44
53
 
45
- Press `?` to enter **Help Mode** where you can browse all documentation files.
46
- Press `ESC` to exit Help Mode.
54
+ Press `?` to open the floating **Help Menu** (Controls / Modes / Scripts).
55
+ Switch sections with `1/2/3` or `Tab`, scroll with `j/k`, and close with `ESC`.
47
56
 
48
57
  ## More Information
49
58
 
@@ -7,8 +7,10 @@ module Rufio
7
7
  # Error display constants
8
8
  BACKTRACE_LINES = 5 # Number of backtrace lines to show
9
9
 
10
- def initialize(start_directory = Dir.pwd)
10
+ # choosedir: T キーで終了した時に最後のディレクトリを書き出すファイル(rufio --shell-init の関数が使う)
11
+ def initialize(start_directory = Dir.pwd, choosedir: nil)
11
12
  @start_directory = File.expand_path(start_directory)
13
+ @choosedir = choosedir
12
14
  # Load configuration including language settings
13
15
  ConfigLoader.load_config
14
16
  end
@@ -28,12 +30,21 @@ module Rufio
28
30
 
29
31
  # アプリケーション開始
30
32
  terminal_ui.start(directory_listing, keybind_handler, file_preview, background_executor)
33
+ write_choosedir(terminal_ui.exit_directory)
31
34
  rescue Interrupt
32
35
  puts "\n\n#{ConfigLoader.message('app.interrupted')}"
33
36
  rescue StandardError => e
34
37
  puts "\n#{ConfigLoader.message('app.error_occurred')}: #{e.message}"
35
38
  puts e.backtrace.first(BACKTRACE_LINES).join("\n")
36
39
  end
40
+
41
+ private
42
+
43
+ def write_choosedir(directory)
44
+ return unless @choosedir && directory
45
+
46
+ File.write(@choosedir, directory)
47
+ end
37
48
  end
38
49
  end
39
50
 
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'dialog_support'
4
+ require_relative 'zip_extractor'
5
+ require_relative 'zip_writer'
6
+
7
+ module Rufio
8
+ # 選択中のアイテムを z キーで圧縮・展開するコントローラ
9
+ #
10
+ # - 選択がすべて .zip ファイルなら、それぞれを現在のディレクトリの <名前>/ に展開する
11
+ # - それ以外は選択アイテムを 1 つの ZIP にまとめて現在のディレクトリに作成する
12
+ # 処理は JobManager のジョブとしてバックグラウンドで実行し、Jobs モードからキャンセルできる。
13
+ class ArchiveController
14
+ include DialogSupport
15
+
16
+ DEFAULT_ARCHIVE_NAME = 'Archive.zip'
17
+ DIALOG_WIDTH = 50
18
+ DIALOG_BORDER_HEIGHT = 4
19
+ PROGRESS_STEP = 5 # 進捗表示を更新する間隔(%)
20
+
21
+ Plan = Struct.new(:action, :source_dir, :dest_dir, :items, :archives, :default_name, keyword_init: true)
22
+
23
+ def initialize(directory_listing, dialog_renderer, selection_manager, job_manager, notification_manager)
24
+ @directory_listing = directory_listing
25
+ @dialog_renderer = dialog_renderer
26
+ @selection_manager = selection_manager
27
+ @job_manager = job_manager
28
+ @notification_manager = notification_manager
29
+ @terminal_ui = nil
30
+ @mutex = Mutex.new
31
+ @finished_dirs = []
32
+ @progress_changed = false
33
+ end
34
+
35
+ def set_terminal_ui(terminal_ui)
36
+ @terminal_ui = terminal_ui
37
+ end
38
+
39
+ def set_directory_listing(directory_listing)
40
+ @directory_listing = directory_listing
41
+ end
42
+
43
+ # 選択アイテムから実行内容を決める(UI に依存しない)
44
+ def self.plan(source_dir, items, dest_dir)
45
+ items = items.reject { |item| item == '..' }
46
+ return nil if items.empty?
47
+
48
+ paths = items.map { |item| File.join(source_dir, item) }
49
+ if paths.all? { |path| File.file?(path) && File.extname(path).casecmp?('.zip') }
50
+ archives = paths.map { |path| [path, unique_path(dest_dir, File.basename(path, '.*'), '')] }
51
+ Plan.new(action: :extract, source_dir: source_dir, dest_dir: dest_dir, items: items, archives: archives)
52
+ else
53
+ default_name = items.length == 1 ? "#{File.basename(items.first)}.zip" : DEFAULT_ARCHIVE_NAME
54
+ Plan.new(action: :compress, source_dir: source_dir, dest_dir: dest_dir, items: items, default_name: default_name)
55
+ end
56
+ end
57
+
58
+ # dir/base<ext> が既にあれば "base 2<ext>", "base 3<ext>" ... を返す
59
+ def self.unique_path(dir, base, ext)
60
+ candidate = File.join(dir, "#{base}#{ext}")
61
+ number = 2
62
+ while File.exist?(candidate) || File.symlink?(candidate)
63
+ candidate = File.join(dir, "#{base} #{number}#{ext}")
64
+ number += 1
65
+ end
66
+ candidate
67
+ end
68
+
69
+ # 入力されたアーカイブ名を検証して .zip を補う(不正なら nil)
70
+ def self.archive_name(input, default_name)
71
+ name = input.to_s.strip
72
+ name = default_name if name.empty?
73
+ return nil if name.match?(%r{[/\\\0]}) || ['.', '..'].include?(name)
74
+
75
+ File.extname(name).casecmp?('.zip') ? name : "#{name}.zip"
76
+ end
77
+
78
+ # z キー(選択あり)の入口
79
+ def archive_selected
80
+ return false if @selection_manager.empty?
81
+
82
+ current_path = @directory_listing&.current_path || Dir.pwd
83
+ source_dir = @selection_manager.source_directory || current_path
84
+ plan = self.class.plan(source_dir, @selection_manager.selected_items, current_path)
85
+ return false unless plan
86
+
87
+ started = plan.action == :extract ? confirm_and_extract(plan) : prompt_and_compress(plan)
88
+ @selection_manager.clear if started
89
+ @terminal_ui&.refresh_display
90
+ started
91
+ end
92
+
93
+ def start_extract(zip_path, dest_dir)
94
+ run_job("Extract #{File.basename(zip_path)}", [File.dirname(dest_dir), dest_dir]) do |job, progress|
95
+ created = !File.exist?(dest_dir)
96
+ begin
97
+ result = ZipExtractor.new(zip_path, dest_dir, progress: progress, cancelled: -> { job.cancelled? }).extract
98
+ rescue StandardError
99
+ # 新しく作った展開先だけを片付ける(既存ディレクトリには触れない)
100
+ FileUtils.rm_rf(dest_dir) if created
101
+ raise
102
+ end
103
+ job.append_log("Extracted #{result.extracted.length} item(s) to #{dest_dir}")
104
+ result.skipped.each { |skip| job.append_log("Skipped #{skip[:name]} (#{skip[:reason]})") }
105
+ end
106
+ end
107
+
108
+ def start_compress(zip_path, source_dir, items)
109
+ run_job("Compress #{File.basename(zip_path)}", [File.dirname(zip_path)]) do |job, progress|
110
+ result = ZipWriter.new(zip_path, source_dir, items, progress: progress, cancelled: -> { job.cancelled? }).write
111
+ job.append_log("Created #{zip_path} with #{result.added.length} item(s)")
112
+ result.skipped.each { |skip| job.append_log("Skipped #{skip[:name]} (#{skip[:reason]})") }
113
+ end
114
+ end
115
+
116
+ # メインループから呼ぶ。表示を更新すべき変化があれば true
117
+ def poll
118
+ finished, changed = @mutex.synchronize do
119
+ dirs = @finished_dirs
120
+ @finished_dirs = []
121
+ progress = @progress_changed
122
+ @progress_changed = false
123
+ [dirs, progress || !dirs.empty?]
124
+ end
125
+
126
+ current = @directory_listing&.current_path
127
+ @directory_listing.refresh if current && finished.any? { |dir| File.expand_path(dir) == File.expand_path(current) }
128
+ changed
129
+ end
130
+
131
+ private
132
+
133
+ def confirm_and_extract(plan)
134
+ lines = ['', "Extract #{plan.archives.length} archive(s)?", '']
135
+ plan.archives.first(5).each do |zip, dest|
136
+ lines << " #{TextUtils.shorten_path_from_end(File.basename(zip), 20)} -> #{TextUtils.shorten_path_from_end(File.basename(dest), 20)}/"
137
+ end
138
+ lines << " ... #{plan.archives.length - 5} more" if plan.archives.length > 5
139
+ lines += ['', "To: #{TextUtils.shorten_path_from_end(plan.dest_dir, 40)}", '', ' [Y]es - Extract', ' [N]o - Cancel', '']
140
+
141
+ return false unless confirm_dialog('Extract Archives', lines, dialog_options(lines))
142
+
143
+ plan.archives.each { |zip, dest| start_extract(zip, dest) }
144
+ true
145
+ end
146
+
147
+ def prompt_and_compress(plan)
148
+ input = @dialog_renderer.show_input_dialog(
149
+ "Compress #{plan.items.length} item(s)",
150
+ "Archive name (Enter: #{plan.default_name}):",
151
+ { width: DIALOG_WIDTH, border_color: "\e[36m", title_color: "\e[1;36m", content_color: "\e[37m" }
152
+ )
153
+ return false if input.nil?
154
+
155
+ name = self.class.archive_name(input, plan.default_name)
156
+ zip_path = name && File.join(plan.dest_dir, name)
157
+ if zip_path.nil? || File.exist?(zip_path)
158
+ message = zip_path ? "Already exists: #{name}" : "Invalid archive name: #{input}"
159
+ @notification_manager&.add(message, :error, duration: 3, exit_code: nil)
160
+ return false
161
+ end
162
+
163
+ start_compress(zip_path, plan.source_dir, plan.items)
164
+ true
165
+ end
166
+
167
+ def dialog_options(lines)
168
+ {
169
+ width: DIALOG_WIDTH,
170
+ height: DIALOG_BORDER_HEIGHT + lines.length,
171
+ border_color: "\e[36m",
172
+ title_color: "\e[1;36m",
173
+ content_color: "\e[37m"
174
+ }
175
+ end
176
+
177
+ # refresh_dirs: 完了時に表示中なら再読み込みするディレクトリ
178
+ def run_job(name, refresh_dirs)
179
+ job = @job_manager.add_job(name: name, path: refresh_dirs.first, command: nil)
180
+ job.start
181
+ last_percent = -PROGRESS_STEP
182
+ progress = lambda do |done, total|
183
+ percent = total.zero? ? 100 : (done * 100 / total)
184
+ next if percent < last_percent + PROGRESS_STEP
185
+
186
+ last_percent = percent
187
+ job.name = "#{name} #{percent}%"
188
+ @mutex.synchronize { @progress_changed = true }
189
+ end
190
+
191
+ Thread.new do
192
+ begin
193
+ yield job, progress
194
+ job.name = name
195
+ job.complete(exit_code: 0) unless job.cancelled?
196
+ rescue ZipExtractor::CancelledError, ZipWriter::CancelledError
197
+ job.name = name
198
+ job.append_log('Cancelled')
199
+ rescue StandardError => e
200
+ job.name = name
201
+ job.append_log("Error: #{e.message}")
202
+ job.fail(exit_code: 1) unless job.cancelled?
203
+ ensure
204
+ @job_manager.notify_completion(job) unless job.cancelled?
205
+ @mutex.synchronize { @finished_dirs.concat(refresh_dirs) }
206
+ end
207
+ end
208
+ job
209
+ end
210
+ end
211
+ end
@@ -135,6 +135,15 @@ module Rufio
135
135
  @completion_message
136
136
  end
137
137
 
138
+ # 完了メッセージを一度だけ受け取る。実行中は消費しない。
139
+ def consume_completion_message
140
+ return nil if running?
141
+
142
+ message = @completion_message
143
+ @completion_message = nil
144
+ message
145
+ end
146
+
138
147
  # 現在実行中のコマンド名を取得
139
148
  # @return [String, nil] コマンド名(実行中でない場合はnil)
140
149
  def current_command
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rufio
4
+ # 完了通知とフッターランプで共有する実行状態・表示期限。
5
+ # 描画側は message を読むだけにし、状態の更新はメインループで行う。
6
+ class BackgroundStatus
7
+ POLL_INTERVAL = 0.1
8
+ DISPLAY_DURATION = 3.0
9
+
10
+ attr_reader :message
11
+
12
+ def initialize(now: Time.now)
13
+ @last_poll = now
14
+ @running = false
15
+ @message = nil
16
+ @expires_at = nil
17
+ end
18
+
19
+ # 描画に関わる状態が変化した場合だけ true を返す。
20
+ def update(executor, now: Time.now)
21
+ changed = expire(now)
22
+ running = executor ? executor.running? : false
23
+ if running != @running
24
+ @running = running
25
+ changed = true
26
+ end
27
+
28
+ if executor && now - @last_poll > POLL_INTERVAL
29
+ @last_poll = now
30
+ if (message = executor.consume_completion_message)
31
+ @message = message
32
+ @expires_at = now + DISPLAY_DURATION
33
+ changed = true
34
+ end
35
+ end
36
+ changed
37
+ end
38
+
39
+ private
40
+
41
+ def expire(now)
42
+ return false unless @expires_at && now >= @expires_at
43
+
44
+ @message = nil
45
+ @expires_at = nil
46
+ true
47
+ end
48
+ end
49
+ end