rufio 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/README.md +74 -3
  4. data/README_ja.md +71 -2
  5. data/Rakefile +14 -1
  6. data/bin/rufio +31 -2
  7. data/docs/fast_zip_design_ja.md +167 -0
  8. data/info/help.md +2 -0
  9. data/info/keybindings.md +3 -1
  10. data/info/welcome.md +1 -1
  11. data/lib/rufio/application.rb +12 -1
  12. data/lib/rufio/archive_controller.rb +211 -0
  13. data/lib/rufio/background_command_executor.rb +9 -0
  14. data/lib/rufio/background_status.rb +49 -0
  15. data/lib/rufio/bookmark_controller.rb +38 -121
  16. data/lib/rufio/bookmark_manager.rb +77 -331
  17. data/lib/rufio/command_completion.rb +6 -1
  18. data/lib/rufio/command_history.rb +5 -2
  19. data/lib/rufio/command_mode.rb +40 -134
  20. data/lib/rufio/command_mode_ui.rb +4 -42
  21. data/lib/rufio/config.rb +88 -136
  22. data/lib/rufio/config_loader.rb +3 -11
  23. data/lib/rufio/dialog_renderer.rb +64 -152
  24. data/lib/rufio/dialog_support.rb +69 -0
  25. data/lib/rufio/document_text_extractor.rb +167 -0
  26. data/lib/rufio/file_operation_controller.rb +49 -222
  27. data/lib/rufio/file_preview.rb +46 -2
  28. data/lib/rufio/global_search.rb +121 -9
  29. data/lib/rufio/help_menu.rb +8 -2
  30. data/lib/rufio/interpreter_resolver.rb +6 -11
  31. data/lib/rufio/job_manager.rb +2 -5
  32. data/lib/rufio/key_input.rb +77 -0
  33. data/lib/rufio/keybind_handler.rb +81 -115
  34. data/lib/rufio/local_script_scanner.rb +6 -18
  35. data/lib/rufio/native/aarch64-linux-gnu/librufio_zig.so +0 -0
  36. data/lib/rufio/native/aarch64-linux-musl/librufio_zig.so +0 -0
  37. data/lib/rufio/native/aarch64-macos/librufio_zig.dylib +0 -0
  38. data/lib/rufio/native/aarch64-windows/rufio_zig.dll +0 -0
  39. data/lib/rufio/native/x86_64-linux-gnu/librufio_zig.so +0 -0
  40. data/lib/rufio/native/x86_64-linux-musl/librufio_zig.so +0 -0
  41. data/lib/rufio/native/x86_64-macos/librufio_zig.dylib +0 -0
  42. data/lib/rufio/native/x86_64-windows/rufio_zig.dll +0 -0
  43. data/lib/rufio/native_content_search.rb +153 -0
  44. data/lib/rufio/native_scanner.rb +13 -68
  45. data/lib/rufio/native_scanner_zig.rb +106 -56
  46. data/lib/rufio/native_zip.rb +121 -0
  47. data/lib/rufio/navigation_controller.rb +18 -20
  48. data/lib/rufio/screen.rb +2 -12
  49. data/lib/rufio/script_executor.rb +6 -73
  50. data/lib/rufio/script_file.rb +44 -0
  51. data/lib/rufio/script_path_manager.rb +9 -43
  52. data/lib/rufio/script_runner.rb +13 -50
  53. data/lib/rufio/search_controller.rb +1 -1
  54. data/lib/rufio/shell_command_completion.rb +1 -5
  55. data/lib/rufio/shell_init.rb +90 -0
  56. data/lib/rufio/task_status.rb +5 -1
  57. data/lib/rufio/terminal_ui.rb +55 -294
  58. data/lib/rufio/text_utils.rb +26 -3
  59. data/lib/rufio/ui_renderer.rb +63 -40
  60. data/lib/rufio/version.rb +1 -1
  61. data/lib/rufio/zip_archive.rb +314 -0
  62. data/lib/rufio/zip_extractor.rb +346 -0
  63. data/lib/rufio/zip_writer.rb +268 -0
  64. data/lib/rufio/zoxide_integration.rb +5 -43
  65. data/lib/rufio.rb +5 -0
  66. data/lib_zig/rufio_native/Makefile +54 -17
  67. data/lib_zig/rufio_native/bench.sh +36 -0
  68. data/lib_zig/rufio_native/build.zig +32 -35
  69. data/lib_zig/rufio_native/src/cli.zig +99 -0
  70. data/lib_zig/rufio_native/src/main.zig +187 -83
  71. data/lib_zig/rufio_native/src/os.zig +889 -0
  72. data/lib_zig/rufio_native/src/search.zig +919 -0
  73. data/lib_zig/rufio_native/src/search_test.zig +88 -0
  74. data/lib_zig/rufio_native/src/zip.zig +482 -0
  75. metadata +125 -3
  76. data/lib/rufio/native/rufio_zig.bundle +0 -0
@@ -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
@@ -1,9 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'dialog_support'
4
+
3
5
  module Rufio
4
6
  # ブックマーク・zoxide・スクリプトパス専用コントローラ
5
7
  # KeybindHandler からブックマーク系・zoxide・スクリプトパス系メソッドを分離し、単一責任原則に準拠
6
8
  class BookmarkController
9
+ include DialogSupport
10
+
7
11
  def initialize(directory_listing, bookmark_manager, dialog_renderer, nav_controller,
8
12
  script_path_manager, notification_manager, zoxide_integration)
9
13
  @directory_listing = directory_listing
@@ -78,8 +82,6 @@ module Rufio
78
82
  end
79
83
 
80
84
  def show_bookmark_menu
81
- current_path = @directory_listing&.current_path || Dir.pwd
82
-
83
85
  menu_items = [
84
86
  '1. Add current dir to bookmarks',
85
87
  '2. Add to script paths',
@@ -154,56 +156,14 @@ module Rufio
154
156
  # 存在しないパスのブックマークはスキップする
155
157
  # @return [Integer, nil] 新しいブックマークインデックス、またはnil
156
158
  def goto_next_bookmark
157
- bookmarks = @bookmark_manager.list
158
- return nil unless bookmarks&.any?
159
-
160
- current_path = @directory_listing.current_path
161
- current_position = bookmarks.find_index { |bm| bm[:path] == current_path }
162
- start_idx = if current_position
163
- (current_position + 1) % bookmarks.length
164
- elsif @last_bookmark_idx
165
- (@last_bookmark_idx + 1) % bookmarks.length
166
- else
167
- 0
168
- end
169
-
170
- bookmarks.length.times do |i|
171
- idx = (start_idx + i) % bookmarks.length
172
- next unless Dir.exist?(bookmarks[idx][:path])
173
-
174
- @last_bookmark_idx = idx
175
- navigate_to_directory(bookmarks[idx][:path])
176
- return @last_bookmark_idx
177
- end
178
- nil
159
+ cycle_bookmark(1)
179
160
  end
180
161
 
181
162
  # 前のブックマークに移動(Shift+Tabキー)
182
163
  # 存在しないパスのブックマークはスキップする
183
164
  # @return [Integer, nil] 新しいブックマークインデックス、またはnil
184
165
  def goto_prev_bookmark
185
- bookmarks = @bookmark_manager.list
186
- return nil unless bookmarks&.any?
187
-
188
- current_path = @directory_listing.current_path
189
- current_position = bookmarks.find_index { |bm| bm[:path] == current_path }
190
- start_idx = if current_position
191
- (current_position - 1 + bookmarks.length) % bookmarks.length
192
- elsif @last_bookmark_idx
193
- (@last_bookmark_idx - 1 + bookmarks.length) % bookmarks.length
194
- else
195
- bookmarks.length - 1
196
- end
197
-
198
- bookmarks.length.times do |i|
199
- idx = (start_idx - i + bookmarks.length) % bookmarks.length
200
- next unless Dir.exist?(bookmarks[idx][:path])
201
-
202
- @last_bookmark_idx = idx
203
- navigate_to_directory(bookmarks[idx][:path])
204
- return @last_bookmark_idx
205
- end
206
- nil
166
+ cycle_bookmark(-1)
207
167
  end
208
168
 
209
169
  # ============================
@@ -262,8 +222,6 @@ module Rufio
262
222
  end
263
223
 
264
224
  selected_index = 0
265
- screen = @terminal_ui&.screen
266
- renderer = @terminal_ui&.renderer
267
225
 
268
226
  loop do
269
227
  menu_items = paths.each_with_index.map do |path, i|
@@ -277,30 +235,13 @@ module Rufio
277
235
  width = 50
278
236
  height = content_lines.length + 4
279
237
 
280
- if screen && renderer
281
- screen.enable_overlay
282
- x, y = @dialog_renderer.calculate_center(width, height)
283
- @dialog_renderer.draw_floating_window_to_overlay(screen, x, y, width, height, title, content_lines, {
284
- border_color: "\e[35m",
285
- title_color: "\e[1;35m",
286
- content_color: "\e[37m"
287
- })
288
- renderer.render(screen)
289
-
290
- key = STDIN.getch
291
- screen.disable_overlay
292
- renderer.render(screen)
293
- else
294
- x, y = @dialog_renderer.calculate_center(width, height)
295
- @dialog_renderer.draw_floating_window(x, y, width, height, title, content_lines, {
296
- border_color: "\e[35m",
297
- title_color: "\e[1;35m",
298
- content_color: "\e[37m"
299
- })
300
-
301
- key = STDIN.getch
302
- @dialog_renderer.clear_area(x, y, width, height)
303
- end
238
+ key = show_overlay_dialog(title, content_lines, {
239
+ width: width,
240
+ height: height,
241
+ border_color: "\e[35m",
242
+ title_color: "\e[1;35m",
243
+ content_color: "\e[37m"
244
+ })
304
245
 
305
246
  case key
306
247
  when 'j'
@@ -358,59 +299,38 @@ module Rufio
358
299
  # ============================
359
300
 
360
301
  def navigate_to_directory(path)
361
- return false unless @directory_listing
362
-
363
- result = @directory_listing.navigate_to_path(path)
364
- if result
365
- @nav_controller.select_index(0)
366
- @nav_controller.clear_filter_mode
367
- true
368
- else
369
- false
370
- end
302
+ @nav_controller.navigate_to_directory(path)
371
303
  end
372
304
 
373
305
  private
374
306
 
375
- def show_overlay_dialog(title, content_lines, options = {}, &block)
376
- use_overlay = @terminal_ui &&
377
- @terminal_ui.respond_to?(:screen) &&
378
- @terminal_ui.respond_to?(:renderer) &&
379
- @terminal_ui.screen &&
380
- @terminal_ui.renderer
381
-
382
- if use_overlay
383
- @terminal_ui.show_overlay_dialog(title, content_lines, options, &block)
384
- else
385
- width = options[:width]
386
- height = options[:height]
387
-
388
- unless width && height
389
- width, height = @dialog_renderer.calculate_dimensions(content_lines, {
390
- title: title,
391
- min_width: options[:min_width] || 40,
392
- max_width: options[:max_width] || 80
393
- })
394
- end
395
-
396
- x, y = @dialog_renderer.calculate_center(width, height)
397
-
398
- @dialog_renderer.draw_floating_window(x, y, width, height, title, content_lines, {
399
- border_color: options[:border_color] || "\e[37m",
400
- title_color: options[:title_color] || "\e[1;33m",
401
- content_color: options[:content_color] || "\e[37m"
402
- })
403
-
404
- key = block_given? ? yield : STDIN.getch
307
+ def cycle_bookmark(step)
308
+ bookmarks = @bookmark_manager.list
309
+ return nil unless bookmarks&.any?
405
310
 
406
- @dialog_renderer.clear_area(x, y, width, height)
407
- @terminal_ui&.refresh_display
311
+ len = bookmarks.length
312
+ current_path = @directory_listing.current_path
313
+ current_position = bookmarks.find_index { |bm| bm[:path] == current_path }
314
+ start_idx = if current_position
315
+ (current_position + step + len) % len
316
+ elsif @last_bookmark_idx
317
+ (@last_bookmark_idx + step + len) % len
318
+ else
319
+ step > 0 ? 0 : len - 1
320
+ end
321
+
322
+ len.times do |i|
323
+ idx = (start_idx + (i * step) + len) % len
324
+ next unless Dir.exist?(bookmarks[idx][:path])
408
325
 
409
- key
326
+ @last_bookmark_idx = idx
327
+ navigate_to_directory(bookmarks[idx][:path])
328
+ return @last_bookmark_idx
410
329
  end
330
+ nil
411
331
  end
412
332
 
413
- def show_notification(title, message, type)
333
+ def show_notification(title, _message, type)
414
334
  return unless @notification_manager
415
335
 
416
336
  @notification_manager.add(title, type, duration: 3, exit_code: nil)
@@ -424,16 +344,13 @@ module Rufio
424
344
 
425
345
  def wait_for_keypress
426
346
  print ConfigLoader.message('keybind.press_any_key') || 'Press any key to continue...'
427
- STDIN.getch
347
+ KeyInput.getch
428
348
  end
429
349
 
430
350
  def truncate_path(path, max_length)
431
351
  return path if path.length <= max_length
432
352
 
433
- display_path = path.sub(Dir.home, '~')
434
- return display_path if display_path.length <= max_length
435
-
436
- "...#{display_path[-(max_length - 3)..]}"
353
+ TextUtils.shorten_path_from_end(path, max_length, tilde: true)
437
354
  end
438
355
  end
439
356
  end