rufio 0.9.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 +7 -0
- data/CHANGELOG.md +188 -0
- data/CHANGELOG_v0.4.0.md +146 -0
- data/CHANGELOG_v0.5.0.md +26 -0
- data/CHANGELOG_v0.6.0.md +182 -0
- data/CHANGELOG_v0.7.0.md +280 -0
- data/CHANGELOG_v0.8.0.md +267 -0
- data/CHANGELOG_v0.9.0.md +279 -0
- data/README.md +631 -0
- data/README_EN.md +561 -0
- data/Rakefile +156 -0
- data/bin/rufio +34 -0
- data/config_example.rb +88 -0
- data/docs/PLUGIN_GUIDE.md +431 -0
- data/docs/plugin_example.rb +119 -0
- data/lib/rufio/application.rb +32 -0
- data/lib/rufio/bookmark.rb +115 -0
- data/lib/rufio/bookmark_manager.rb +173 -0
- data/lib/rufio/color_helper.rb +150 -0
- data/lib/rufio/command_mode.rb +72 -0
- data/lib/rufio/command_mode_ui.rb +168 -0
- data/lib/rufio/config.rb +199 -0
- data/lib/rufio/config_loader.rb +110 -0
- data/lib/rufio/dialog_renderer.rb +127 -0
- data/lib/rufio/directory_listing.rb +113 -0
- data/lib/rufio/file_opener.rb +140 -0
- data/lib/rufio/file_operations.rb +231 -0
- data/lib/rufio/file_preview.rb +200 -0
- data/lib/rufio/filter_manager.rb +114 -0
- data/lib/rufio/health_checker.rb +246 -0
- data/lib/rufio/keybind_handler.rb +828 -0
- data/lib/rufio/logger.rb +103 -0
- data/lib/rufio/plugin.rb +89 -0
- data/lib/rufio/plugin_config.rb +59 -0
- data/lib/rufio/plugin_manager.rb +84 -0
- data/lib/rufio/plugins/file_operations.rb +44 -0
- data/lib/rufio/selection_manager.rb +79 -0
- data/lib/rufio/terminal_ui.rb +630 -0
- data/lib/rufio/text_utils.rb +108 -0
- data/lib/rufio/version.rb +5 -0
- data/lib/rufio/zoxide_integration.rb +188 -0
- data/lib/rufio.rb +33 -0
- data/publish_gem.zsh +131 -0
- data/rufio.gemspec +40 -0
- data/test_delete/test1.txt +1 -0
- data/test_delete/test2.txt +1 -0
- metadata +189 -0
|
@@ -0,0 +1,828 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'shellwords'
|
|
4
|
+
require_relative 'file_opener'
|
|
5
|
+
require_relative 'filter_manager'
|
|
6
|
+
require_relative 'selection_manager'
|
|
7
|
+
require_relative 'file_operations'
|
|
8
|
+
require_relative 'bookmark_manager'
|
|
9
|
+
require_relative 'zoxide_integration'
|
|
10
|
+
require_relative 'dialog_renderer'
|
|
11
|
+
require_relative 'logger'
|
|
12
|
+
|
|
13
|
+
module Rufio
|
|
14
|
+
class KeybindHandler
|
|
15
|
+
attr_reader :current_index
|
|
16
|
+
|
|
17
|
+
def filter_query
|
|
18
|
+
@filter_manager.filter_query
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# ASCII character range constants
|
|
22
|
+
ASCII_PRINTABLE_START = 32
|
|
23
|
+
ASCII_PRINTABLE_END = 127
|
|
24
|
+
MULTIBYTE_THRESHOLD = 1
|
|
25
|
+
|
|
26
|
+
# Dialog size constants
|
|
27
|
+
CONFIRMATION_DIALOG_WIDTH = 45
|
|
28
|
+
DIALOG_BORDER_HEIGHT = 4
|
|
29
|
+
|
|
30
|
+
# File system operation constants
|
|
31
|
+
FILESYSTEM_SYNC_DELAY = 0.01 # 10ms wait for filesystem sync
|
|
32
|
+
|
|
33
|
+
def initialize
|
|
34
|
+
@current_index = 0
|
|
35
|
+
@directory_listing = nil
|
|
36
|
+
@terminal_ui = nil
|
|
37
|
+
@file_opener = FileOpener.new
|
|
38
|
+
|
|
39
|
+
# New manager classes
|
|
40
|
+
@filter_manager = FilterManager.new
|
|
41
|
+
@selection_manager = SelectionManager.new
|
|
42
|
+
@file_operations = FileOperations.new
|
|
43
|
+
@dialog_renderer = DialogRenderer.new
|
|
44
|
+
@bookmark_manager = BookmarkManager.new(Bookmark.new, @dialog_renderer)
|
|
45
|
+
@zoxide_integration = ZoxideIntegration.new(@dialog_renderer)
|
|
46
|
+
|
|
47
|
+
# Legacy fields for backward compatibility
|
|
48
|
+
@base_directory = nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def set_directory_listing(directory_listing)
|
|
52
|
+
@directory_listing = directory_listing
|
|
53
|
+
@current_index = 0
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def set_terminal_ui(terminal_ui)
|
|
57
|
+
@terminal_ui = terminal_ui
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def set_base_directory(base_dir)
|
|
61
|
+
@base_directory = File.expand_path(base_dir)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def selected_items
|
|
65
|
+
@selection_manager.selected_items
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def is_selected?(entry_name)
|
|
69
|
+
@selection_manager.selected?(entry_name)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def handle_key(key)
|
|
73
|
+
return false unless @directory_listing
|
|
74
|
+
|
|
75
|
+
# フィルターモード中は他のキーバインドを無効化
|
|
76
|
+
return handle_filter_input(key) if @filter_manager.filter_mode
|
|
77
|
+
|
|
78
|
+
case key
|
|
79
|
+
when 'j'
|
|
80
|
+
move_down
|
|
81
|
+
when 'k'
|
|
82
|
+
move_up
|
|
83
|
+
when 'h'
|
|
84
|
+
navigate_parent
|
|
85
|
+
when 'l', "\r", "\n" # l, Enter
|
|
86
|
+
navigate_enter
|
|
87
|
+
when 'g'
|
|
88
|
+
move_to_top
|
|
89
|
+
when 'G'
|
|
90
|
+
move_to_bottom
|
|
91
|
+
when 'r'
|
|
92
|
+
refresh
|
|
93
|
+
when 'o' # o
|
|
94
|
+
open_current_file
|
|
95
|
+
when 'e' # e - open directory in file explorer
|
|
96
|
+
open_directory_in_explorer
|
|
97
|
+
when 'f' # f - filter files
|
|
98
|
+
if @filter_manager.filter_active?
|
|
99
|
+
# フィルタが設定されている場合は再編集モードに入る
|
|
100
|
+
@filter_manager.restart_filter_mode(@directory_listing.list_entries)
|
|
101
|
+
else
|
|
102
|
+
# 新規フィルターモード開始
|
|
103
|
+
start_filter_mode
|
|
104
|
+
end
|
|
105
|
+
when ' ' # Space - toggle selection
|
|
106
|
+
toggle_selection
|
|
107
|
+
when "\e" # ESC
|
|
108
|
+
if @filter_manager.filter_active?
|
|
109
|
+
# フィルタが設定されている場合はクリア
|
|
110
|
+
clear_filter_mode
|
|
111
|
+
true
|
|
112
|
+
else
|
|
113
|
+
false
|
|
114
|
+
end
|
|
115
|
+
when 'q' # q
|
|
116
|
+
exit_request
|
|
117
|
+
when '/' # /
|
|
118
|
+
fzf_search
|
|
119
|
+
when 's' # s - file name search with fzf
|
|
120
|
+
fzf_search
|
|
121
|
+
when 'F' # F - file content search with rga
|
|
122
|
+
rga_search
|
|
123
|
+
when 'a' # a
|
|
124
|
+
create_file
|
|
125
|
+
when 'A' # A
|
|
126
|
+
create_directory
|
|
127
|
+
when 'm' # m - move selected files to base directory
|
|
128
|
+
move_selected_to_base
|
|
129
|
+
when 'p' # p - copy selected files to base directory
|
|
130
|
+
copy_selected_to_base
|
|
131
|
+
when 'x' # x - delete selected files
|
|
132
|
+
delete_selected_files
|
|
133
|
+
when 'b' # b - bookmark operations
|
|
134
|
+
show_bookmark_menu
|
|
135
|
+
when 'z' # z - zoxide history navigation
|
|
136
|
+
show_zoxide_menu
|
|
137
|
+
when '1', '2', '3', '4', '5', '6', '7', '8', '9' # number keys - go to bookmark
|
|
138
|
+
goto_bookmark(key.to_i)
|
|
139
|
+
when ':' # : - command mode
|
|
140
|
+
activate_command_mode
|
|
141
|
+
else
|
|
142
|
+
false # #{ConfigLoader.message('keybind.invalid_key')}
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def select_index(index)
|
|
147
|
+
entries = get_active_entries
|
|
148
|
+
@current_index = [[index, 0].max, entries.length - 1].min
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def current_entry
|
|
152
|
+
entries = get_active_entries
|
|
153
|
+
entries[@current_index]
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def filter_active?
|
|
157
|
+
@filter_manager.filter_active?
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def get_active_entries
|
|
161
|
+
if @filter_manager.filter_active?
|
|
162
|
+
@filter_manager.filtered_entries
|
|
163
|
+
else
|
|
164
|
+
@directory_listing&.list_entries || []
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
private
|
|
169
|
+
|
|
170
|
+
def move_down
|
|
171
|
+
entries = get_active_entries
|
|
172
|
+
@current_index = [@current_index + 1, entries.length - 1].min
|
|
173
|
+
true
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def move_up
|
|
177
|
+
@current_index = [@current_index - 1, 0].max
|
|
178
|
+
true
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def move_to_top
|
|
182
|
+
@current_index = 0
|
|
183
|
+
true
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def move_to_bottom
|
|
187
|
+
entries = get_active_entries
|
|
188
|
+
@current_index = entries.length - 1
|
|
189
|
+
true
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def navigate_enter
|
|
193
|
+
entry = current_entry
|
|
194
|
+
return false unless entry
|
|
195
|
+
|
|
196
|
+
if entry[:type] == 'directory'
|
|
197
|
+
result = @directory_listing.navigate_to(entry[:name])
|
|
198
|
+
if result
|
|
199
|
+
@current_index = 0 # select first entry in new directory
|
|
200
|
+
clear_filter_mode # ディレクトリ移動時にフィルタをリセット
|
|
201
|
+
end
|
|
202
|
+
result
|
|
203
|
+
else
|
|
204
|
+
# do nothing for files (file opening feature may be added in the future)
|
|
205
|
+
false
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def navigate_parent
|
|
210
|
+
result = @directory_listing.navigate_to_parent
|
|
211
|
+
if result
|
|
212
|
+
@current_index = 0 # select first entry in parent directory
|
|
213
|
+
clear_filter_mode # ディレクトリ移動時にフィルタをリセット
|
|
214
|
+
end
|
|
215
|
+
result
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def refresh
|
|
219
|
+
# ウィンドウサイズを更新して画面を再描画
|
|
220
|
+
@terminal_ui&.refresh_display
|
|
221
|
+
|
|
222
|
+
@directory_listing.refresh
|
|
223
|
+
if @filter_manager.filter_active?
|
|
224
|
+
# Re-apply filter with new directory contents
|
|
225
|
+
@filter_manager.update_entries(@directory_listing.list_entries)
|
|
226
|
+
else
|
|
227
|
+
# adjust index to stay within bounds after refresh
|
|
228
|
+
entries = @directory_listing.list_entries
|
|
229
|
+
@current_index = [@current_index, entries.length - 1].min if entries.any?
|
|
230
|
+
end
|
|
231
|
+
true
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def open_current_file
|
|
235
|
+
entry = current_entry
|
|
236
|
+
return false unless entry
|
|
237
|
+
|
|
238
|
+
if entry[:type] == 'file'
|
|
239
|
+
@file_opener.open_file(entry[:path])
|
|
240
|
+
true
|
|
241
|
+
else
|
|
242
|
+
false
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def open_directory_in_explorer
|
|
247
|
+
current_path = @directory_listing&.current_path || Dir.pwd
|
|
248
|
+
@file_opener.open_directory_in_explorer(current_path)
|
|
249
|
+
true
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def exit_request
|
|
253
|
+
true # request exit
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def fzf_search
|
|
257
|
+
return false unless fzf_available?
|
|
258
|
+
|
|
259
|
+
current_path = @directory_listing&.current_path || Dir.pwd
|
|
260
|
+
|
|
261
|
+
# fzfでファイル検索を実行
|
|
262
|
+
# Dir.chdirを使用してディレクトリ移動を安全に行う
|
|
263
|
+
selected_file = nil
|
|
264
|
+
Dir.chdir(current_path) do
|
|
265
|
+
selected_file = `find . -type f | fzf --preview 'cat {}'`.strip
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# ファイルが選択された場合、そのファイルを開く
|
|
269
|
+
if !selected_file.empty?
|
|
270
|
+
full_path = File.expand_path(selected_file, current_path)
|
|
271
|
+
@file_opener.open_file(full_path) if File.exist?(full_path)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
true
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def fzf_available?
|
|
278
|
+
system('which fzf > /dev/null 2>&1')
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def rga_search
|
|
282
|
+
return false unless rga_available?
|
|
283
|
+
|
|
284
|
+
current_path = @directory_listing&.current_path || Dir.pwd
|
|
285
|
+
|
|
286
|
+
# input search keyword
|
|
287
|
+
print ConfigLoader.message('keybind.search_text')
|
|
288
|
+
search_query = STDIN.gets.chomp
|
|
289
|
+
return false if search_query.empty?
|
|
290
|
+
|
|
291
|
+
# execute rga file content search
|
|
292
|
+
# Dir.chdirを使用してディレクトリ移動を安全に行う
|
|
293
|
+
search_results = nil
|
|
294
|
+
Dir.chdir(current_path) do
|
|
295
|
+
# Shellwords.escapeで検索クエリをエスケープ
|
|
296
|
+
escaped_query = Shellwords.escape(search_query)
|
|
297
|
+
search_results = `rga --line-number --with-filename #{escaped_query} . 2>/dev/null`
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
if search_results.empty?
|
|
301
|
+
puts "\n#{ConfigLoader.message('keybind.no_matches')}"
|
|
302
|
+
print ConfigLoader.message('keybind.press_any_key')
|
|
303
|
+
STDIN.getch
|
|
304
|
+
return true
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# pass results to fzf for selection
|
|
308
|
+
selected_result = IO.popen('fzf', 'r+') do |fzf|
|
|
309
|
+
fzf.write(search_results)
|
|
310
|
+
fzf.close_write
|
|
311
|
+
fzf.read.strip
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# extract file path and line number from selected result
|
|
315
|
+
if !selected_result.empty? && selected_result.match(/^(.+?):(\d+):/)
|
|
316
|
+
file_path = ::Regexp.last_match(1)
|
|
317
|
+
line_number = ::Regexp.last_match(2).to_i
|
|
318
|
+
full_path = File.expand_path(file_path, current_path)
|
|
319
|
+
|
|
320
|
+
@file_opener.open_file_with_line(full_path, line_number) if File.exist?(full_path)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
true
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def rga_available?
|
|
327
|
+
system('which rga > /dev/null 2>&1')
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def start_filter_mode
|
|
331
|
+
@filter_manager.start_filter_mode(@directory_listing.list_entries)
|
|
332
|
+
@current_index = 0
|
|
333
|
+
true
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def handle_filter_input(key)
|
|
337
|
+
result = @filter_manager.handle_filter_input(key)
|
|
338
|
+
|
|
339
|
+
case result
|
|
340
|
+
when :exit_clear
|
|
341
|
+
clear_filter_mode
|
|
342
|
+
when :exit_keep
|
|
343
|
+
exit_filter_mode_keep_filter
|
|
344
|
+
when :backspace_exit
|
|
345
|
+
clear_filter_mode
|
|
346
|
+
when :continue
|
|
347
|
+
@current_index = [@current_index, [@filter_manager.filtered_entries.length - 1, 0].max].min
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
true
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def exit_filter_mode_keep_filter
|
|
354
|
+
# フィルタを維持したまま通常モードに戻る
|
|
355
|
+
@filter_manager.exit_filter_mode_keep_filter
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def clear_filter_mode
|
|
359
|
+
# フィルタをクリアして通常モードに戻る
|
|
360
|
+
@filter_manager.clear_filter
|
|
361
|
+
@current_index = 0
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def exit_filter_mode
|
|
365
|
+
# 既存メソッド(後方互換用)
|
|
366
|
+
clear_filter_mode
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def create_file
|
|
370
|
+
current_path = @directory_listing&.current_path || Dir.pwd
|
|
371
|
+
|
|
372
|
+
# カーソルを画面下部に移動して入力プロンプトを表示
|
|
373
|
+
move_to_input_line
|
|
374
|
+
print ConfigLoader.message('keybind.input_filename')
|
|
375
|
+
STDOUT.flush
|
|
376
|
+
|
|
377
|
+
filename = read_line_with_escape
|
|
378
|
+
return false if filename.nil? || filename.empty?
|
|
379
|
+
|
|
380
|
+
# FileOperationsを使用してファイルを作成
|
|
381
|
+
result = @file_operations.create_file(current_path, filename)
|
|
382
|
+
|
|
383
|
+
# ディレクトリ表示を更新
|
|
384
|
+
if result.success
|
|
385
|
+
@directory_listing.refresh
|
|
386
|
+
|
|
387
|
+
# 作成したファイルを選択状態にする
|
|
388
|
+
entries = @directory_listing.list_entries
|
|
389
|
+
new_file_index = entries.find_index { |entry| entry[:name] == filename }
|
|
390
|
+
@current_index = new_file_index if new_file_index
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# 結果を表示
|
|
394
|
+
puts "\n#{result.message}"
|
|
395
|
+
print ConfigLoader.message('keybind.press_any_key')
|
|
396
|
+
STDIN.getch
|
|
397
|
+
result.success
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def create_directory
|
|
401
|
+
current_path = @directory_listing&.current_path || Dir.pwd
|
|
402
|
+
|
|
403
|
+
# カーソルを画面下部に移動して入力プロンプトを表示
|
|
404
|
+
move_to_input_line
|
|
405
|
+
print ConfigLoader.message('keybind.input_dirname')
|
|
406
|
+
STDOUT.flush
|
|
407
|
+
|
|
408
|
+
dirname = read_line_with_escape
|
|
409
|
+
return false if dirname.nil? || dirname.empty?
|
|
410
|
+
|
|
411
|
+
# FileOperationsを使用してディレクトリを作成
|
|
412
|
+
result = @file_operations.create_directory(current_path, dirname)
|
|
413
|
+
|
|
414
|
+
# ディレクトリ表示を更新
|
|
415
|
+
if result.success
|
|
416
|
+
@directory_listing.refresh
|
|
417
|
+
|
|
418
|
+
# 作成したディレクトリを選択状態にする
|
|
419
|
+
entries = @directory_listing.list_entries
|
|
420
|
+
new_dir_index = entries.find_index { |entry| entry[:name] == dirname }
|
|
421
|
+
@current_index = new_dir_index if new_dir_index
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# 結果を表示
|
|
425
|
+
puts "\n#{result.message}"
|
|
426
|
+
print ConfigLoader.message('keybind.press_any_key')
|
|
427
|
+
STDIN.getch
|
|
428
|
+
result.success
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def toggle_selection
|
|
432
|
+
entry = current_entry
|
|
433
|
+
return false unless entry
|
|
434
|
+
|
|
435
|
+
@selection_manager.toggle_selection(entry)
|
|
436
|
+
true
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def move_selected_to_base
|
|
440
|
+
return false if @selection_manager.empty? || @base_directory.nil?
|
|
441
|
+
|
|
442
|
+
if show_confirmation_dialog('Move', @selection_manager.count)
|
|
443
|
+
current_path = @directory_listing&.current_path || Dir.pwd
|
|
444
|
+
result = @file_operations.move(@selection_manager.selected_items, current_path, @base_directory)
|
|
445
|
+
|
|
446
|
+
# Show result and refresh
|
|
447
|
+
show_operation_result(result)
|
|
448
|
+
@selection_manager.clear
|
|
449
|
+
@directory_listing.refresh if @directory_listing
|
|
450
|
+
true
|
|
451
|
+
else
|
|
452
|
+
false
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def copy_selected_to_base
|
|
457
|
+
return false if @selection_manager.empty? || @base_directory.nil?
|
|
458
|
+
|
|
459
|
+
if show_confirmation_dialog('Copy', @selection_manager.count)
|
|
460
|
+
current_path = @directory_listing&.current_path || Dir.pwd
|
|
461
|
+
result = @file_operations.copy(@selection_manager.selected_items, current_path, @base_directory)
|
|
462
|
+
|
|
463
|
+
# Show result and refresh
|
|
464
|
+
show_operation_result(result)
|
|
465
|
+
@selection_manager.clear
|
|
466
|
+
@directory_listing.refresh if @directory_listing
|
|
467
|
+
true
|
|
468
|
+
else
|
|
469
|
+
false
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def show_confirmation_dialog(operation, count)
|
|
474
|
+
print "\n#{operation} #{count} item(s)? (y/n): "
|
|
475
|
+
response = STDIN.gets.chomp.downcase
|
|
476
|
+
%w[y yes].include?(response)
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
# Helper method to show operation result
|
|
480
|
+
def show_operation_result(result)
|
|
481
|
+
if result.errors.any?
|
|
482
|
+
puts "\n#{result.message}"
|
|
483
|
+
result.errors.each { |error| puts " - #{error}" }
|
|
484
|
+
else
|
|
485
|
+
puts "\n#{result.message}"
|
|
486
|
+
end
|
|
487
|
+
print 'Press any key to continue...'
|
|
488
|
+
STDIN.getch
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
def delete_selected_files
|
|
492
|
+
return false if @selection_manager.empty?
|
|
493
|
+
|
|
494
|
+
if show_delete_confirmation(@selection_manager.count)
|
|
495
|
+
current_path = @directory_listing&.current_path || Dir.pwd
|
|
496
|
+
result = @file_operations.delete(@selection_manager.selected_items, current_path)
|
|
497
|
+
|
|
498
|
+
# Show detailed delete result
|
|
499
|
+
show_deletion_result(result.count, @selection_manager.count, result.errors)
|
|
500
|
+
@selection_manager.clear
|
|
501
|
+
@directory_listing.refresh if @directory_listing
|
|
502
|
+
true
|
|
503
|
+
else
|
|
504
|
+
false
|
|
505
|
+
end
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def show_delete_confirmation(count)
|
|
509
|
+
show_floating_delete_confirmation(count)
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
def show_floating_delete_confirmation(count)
|
|
513
|
+
# コンテンツの準備
|
|
514
|
+
title = 'Delete Confirmation'
|
|
515
|
+
content_lines = [
|
|
516
|
+
'',
|
|
517
|
+
"Delete #{count} item(s)?",
|
|
518
|
+
'',
|
|
519
|
+
' [Y]es - Delete',
|
|
520
|
+
' [N]o - Cancel',
|
|
521
|
+
''
|
|
522
|
+
]
|
|
523
|
+
|
|
524
|
+
# ダイアログのサイズ設定(コンテンツに合わせて調整)
|
|
525
|
+
dialog_width = CONFIRMATION_DIALOG_WIDTH
|
|
526
|
+
# タイトルあり: 上枠1 + タイトル1 + 区切り1 + コンテンツ + 下枠1
|
|
527
|
+
dialog_height = DIALOG_BORDER_HEIGHT + content_lines.length
|
|
528
|
+
|
|
529
|
+
# ダイアログの位置を中央に設定
|
|
530
|
+
x, y = @dialog_renderer.calculate_center(dialog_width, dialog_height)
|
|
531
|
+
|
|
532
|
+
# ダイアログの描画
|
|
533
|
+
@dialog_renderer.draw_floating_window(x, y, dialog_width, dialog_height, title, content_lines, {
|
|
534
|
+
border_color: "\e[31m", # 赤色(警告)
|
|
535
|
+
title_color: "\e[1;31m", # 太字赤色
|
|
536
|
+
content_color: "\e[37m" # 白色
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
# フラッシュしてユーザーの注意を引く
|
|
540
|
+
print "\a" # ベル音
|
|
541
|
+
|
|
542
|
+
# キー入力待機
|
|
543
|
+
loop do
|
|
544
|
+
input = STDIN.getch.downcase
|
|
545
|
+
|
|
546
|
+
case input
|
|
547
|
+
when 'y'
|
|
548
|
+
# ダイアログをクリア
|
|
549
|
+
@dialog_renderer.clear_area(x, y, dialog_width, dialog_height)
|
|
550
|
+
@terminal_ui&.refresh_display # 画面を再描画
|
|
551
|
+
return true
|
|
552
|
+
when 'n', "\e", "\x03" # n, ESC, Ctrl+C
|
|
553
|
+
# ダイアログをクリア
|
|
554
|
+
@dialog_renderer.clear_area(x, y, dialog_width, dialog_height)
|
|
555
|
+
@terminal_ui&.refresh_display # 画面を再描画
|
|
556
|
+
return false
|
|
557
|
+
when 'q' # qキーでもキャンセル
|
|
558
|
+
@dialog_renderer.clear_area(x, y, dialog_width, dialog_height)
|
|
559
|
+
@terminal_ui&.refresh_display
|
|
560
|
+
return false
|
|
561
|
+
end
|
|
562
|
+
# 無効なキー入力の場合は再度ループ
|
|
563
|
+
end
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
def perform_delete_operation(items)
|
|
567
|
+
Logger.debug('Starting delete operation', context: { items: items, count: items.length })
|
|
568
|
+
|
|
569
|
+
success_count = 0
|
|
570
|
+
error_messages = []
|
|
571
|
+
current_path = @directory_listing&.current_path || Dir.pwd
|
|
572
|
+
|
|
573
|
+
items.each do |item_name|
|
|
574
|
+
item_path = File.join(current_path, item_name)
|
|
575
|
+
Logger.debug("Processing deletion", context: { item: item_name, path: item_path })
|
|
576
|
+
|
|
577
|
+
begin
|
|
578
|
+
# ファイル/ディレクトリの存在確認
|
|
579
|
+
unless File.exist?(item_path)
|
|
580
|
+
error_messages << "#{item_name}: File not found"
|
|
581
|
+
Logger.warn("File not found for deletion", context: { item: item_name })
|
|
582
|
+
next
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
is_directory = File.directory?(item_path)
|
|
586
|
+
Logger.debug("Item type determined", context: { item: item_name, type: is_directory ? 'Directory' : 'File' })
|
|
587
|
+
|
|
588
|
+
if is_directory
|
|
589
|
+
FileUtils.rm_rf(item_path)
|
|
590
|
+
else
|
|
591
|
+
FileUtils.rm(item_path)
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
# 削除が実際に成功したかを確認
|
|
595
|
+
sleep(FILESYSTEM_SYNC_DELAY) # wait for filesystem sync
|
|
596
|
+
still_exists = File.exist?(item_path)
|
|
597
|
+
|
|
598
|
+
if still_exists
|
|
599
|
+
error_messages << "#{item_name}: Deletion failed"
|
|
600
|
+
Logger.error("Deletion failed", context: { item: item_name, still_exists: true })
|
|
601
|
+
else
|
|
602
|
+
success_count += 1
|
|
603
|
+
Logger.debug("Deletion successful", context: { item: item_name })
|
|
604
|
+
end
|
|
605
|
+
rescue StandardError => e
|
|
606
|
+
error_messages << "#{item_name}: #{e.message}"
|
|
607
|
+
Logger.error("Exception during deletion", exception: e, context: { item: item_name })
|
|
608
|
+
end
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
Logger.debug('Delete operation completed', context: {
|
|
612
|
+
success_count: success_count,
|
|
613
|
+
total_count: items.length,
|
|
614
|
+
error_count: error_messages.length,
|
|
615
|
+
has_errors: !error_messages.empty?
|
|
616
|
+
})
|
|
617
|
+
|
|
618
|
+
# 削除結果をフローティングウィンドウで表示
|
|
619
|
+
show_deletion_result(success_count, items.length, error_messages)
|
|
620
|
+
|
|
621
|
+
# 削除完了後の処理
|
|
622
|
+
@selection_manager.clear
|
|
623
|
+
@directory_listing.refresh if @directory_listing
|
|
624
|
+
|
|
625
|
+
true
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
def show_deletion_result(success_count, total_count, error_messages = [])
|
|
629
|
+
Logger.debug('Showing deletion result dialog', context: {
|
|
630
|
+
success_count: success_count,
|
|
631
|
+
total_count: total_count,
|
|
632
|
+
error_messages: error_messages
|
|
633
|
+
})
|
|
634
|
+
|
|
635
|
+
# エラーメッセージがある場合はダイアログサイズを拡大
|
|
636
|
+
has_errors = !error_messages.empty?
|
|
637
|
+
dialog_width = has_errors ? 50 : 35
|
|
638
|
+
dialog_height = has_errors ? [8 + error_messages.length, 15].min : 6
|
|
639
|
+
|
|
640
|
+
# ダイアログの位置を中央に設定
|
|
641
|
+
x, y = @dialog_renderer.calculate_center(dialog_width, dialog_height)
|
|
642
|
+
|
|
643
|
+
# 成功・失敗に応じた色設定
|
|
644
|
+
if success_count == total_count && !has_errors
|
|
645
|
+
border_color = "\e[32m" # 緑色(成功)
|
|
646
|
+
title_color = "\e[1;32m" # 太字緑色
|
|
647
|
+
title = 'Delete Complete'
|
|
648
|
+
message = "Deleted #{success_count} item(s)"
|
|
649
|
+
else
|
|
650
|
+
border_color = "\e[33m" # 黄色(警告)
|
|
651
|
+
title_color = "\e[1;33m" # 太字黄色
|
|
652
|
+
title = 'Delete Result'
|
|
653
|
+
if success_count == total_count && has_errors
|
|
654
|
+
# 全て削除成功したがエラーメッセージがある場合(本来ここに入らないはず)
|
|
655
|
+
message = "#{success_count} deleted (with error info)"
|
|
656
|
+
else
|
|
657
|
+
failed_count = total_count - success_count
|
|
658
|
+
message = "#{success_count} deleted, #{failed_count} failed"
|
|
659
|
+
end
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
# コンテンツの準備
|
|
663
|
+
content_lines = ['', message]
|
|
664
|
+
|
|
665
|
+
# エラーメッセージがある場合は追加
|
|
666
|
+
if has_errors
|
|
667
|
+
content_lines << ''
|
|
668
|
+
content_lines << 'Error details:'
|
|
669
|
+
error_messages.each { |error| content_lines << " #{error}" }
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
content_lines << ''
|
|
673
|
+
content_lines << 'Press any key to continue...'
|
|
674
|
+
|
|
675
|
+
# ダイアログの描画
|
|
676
|
+
@dialog_renderer.draw_floating_window(x, y, dialog_width, dialog_height, title, content_lines, {
|
|
677
|
+
border_color: border_color,
|
|
678
|
+
title_color: title_color,
|
|
679
|
+
content_color: "\e[37m"
|
|
680
|
+
})
|
|
681
|
+
|
|
682
|
+
# キー入力待機
|
|
683
|
+
STDIN.getch
|
|
684
|
+
|
|
685
|
+
# ダイアログをクリア
|
|
686
|
+
@dialog_renderer.clear_area(x, y, dialog_width, dialog_height)
|
|
687
|
+
@terminal_ui&.refresh_display
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
# ブックマーク機能
|
|
692
|
+
def show_bookmark_menu
|
|
693
|
+
current_path = @directory_listing&.current_path || Dir.pwd
|
|
694
|
+
result = @bookmark_manager.show_menu(current_path)
|
|
695
|
+
|
|
696
|
+
@terminal_ui&.refresh_display
|
|
697
|
+
|
|
698
|
+
case result[:action]
|
|
699
|
+
when :add
|
|
700
|
+
success = @bookmark_manager.add_interactive(result[:path])
|
|
701
|
+
wait_for_keypress
|
|
702
|
+
success
|
|
703
|
+
when :list
|
|
704
|
+
@bookmark_manager.list_interactive
|
|
705
|
+
wait_for_keypress
|
|
706
|
+
true
|
|
707
|
+
when :remove
|
|
708
|
+
@bookmark_manager.remove_interactive
|
|
709
|
+
wait_for_keypress
|
|
710
|
+
true
|
|
711
|
+
when :navigate
|
|
712
|
+
goto_bookmark(result[:number])
|
|
713
|
+
else
|
|
714
|
+
false
|
|
715
|
+
end
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
def goto_bookmark(number)
|
|
719
|
+
bookmark = @bookmark_manager.find_by_number(number)
|
|
720
|
+
|
|
721
|
+
return show_error_and_wait('bookmark.not_found', number) unless bookmark
|
|
722
|
+
return show_error_and_wait('bookmark.path_not_exist', bookmark[:path]) unless @bookmark_manager.path_exists?(bookmark)
|
|
723
|
+
|
|
724
|
+
# ディレクトリに移動
|
|
725
|
+
if navigate_to_directory(bookmark[:path])
|
|
726
|
+
puts "\n#{ConfigLoader.message('bookmark.navigated') || 'Navigated to bookmark'}: #{bookmark[:name]}"
|
|
727
|
+
sleep(0.5) # 短時間表示
|
|
728
|
+
true
|
|
729
|
+
else
|
|
730
|
+
show_error_and_wait('bookmark.navigate_failed', bookmark[:name])
|
|
731
|
+
end
|
|
732
|
+
end
|
|
733
|
+
|
|
734
|
+
# ヘルパーメソッド
|
|
735
|
+
def wait_for_keypress
|
|
736
|
+
print ConfigLoader.message('keybind.press_any_key') || 'Press any key to continue...'
|
|
737
|
+
STDIN.getch
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
def show_error_and_wait(message_key, value)
|
|
741
|
+
puts "\n#{ConfigLoader.message(message_key) || message_key}: #{value}"
|
|
742
|
+
wait_for_keypress
|
|
743
|
+
false
|
|
744
|
+
end
|
|
745
|
+
|
|
746
|
+
def navigate_to_directory(path)
|
|
747
|
+
result = @directory_listing.navigate_to_path(path)
|
|
748
|
+
if result
|
|
749
|
+
@current_index = 0
|
|
750
|
+
clear_filter_mode
|
|
751
|
+
true
|
|
752
|
+
else
|
|
753
|
+
false
|
|
754
|
+
end
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
# zoxide 機能
|
|
758
|
+
def show_zoxide_menu
|
|
759
|
+
selected_path = @zoxide_integration.show_menu
|
|
760
|
+
|
|
761
|
+
if selected_path && Dir.exist?(selected_path)
|
|
762
|
+
if navigate_to_directory(selected_path)
|
|
763
|
+
@zoxide_integration.add_to_history(selected_path)
|
|
764
|
+
true
|
|
765
|
+
else
|
|
766
|
+
false
|
|
767
|
+
end
|
|
768
|
+
else
|
|
769
|
+
@terminal_ui&.refresh_display
|
|
770
|
+
false
|
|
771
|
+
end
|
|
772
|
+
end
|
|
773
|
+
|
|
774
|
+
# コマンドモードを起動
|
|
775
|
+
def activate_command_mode
|
|
776
|
+
@terminal_ui&.activate_command_mode
|
|
777
|
+
true
|
|
778
|
+
end
|
|
779
|
+
|
|
780
|
+
private
|
|
781
|
+
|
|
782
|
+
# カーソルを画面下部の入力行に移動
|
|
783
|
+
def move_to_input_line
|
|
784
|
+
# 画面の最終行にカーソルを移動
|
|
785
|
+
# terminal_uiから画面の高さを取得できない場合は、24行目(デフォルト)を使用
|
|
786
|
+
screen_height = @terminal_ui&.instance_variable_get(:@screen_height) || 24
|
|
787
|
+
print "\e[#{screen_height};1H" # 最終行の先頭にカーソル移動
|
|
788
|
+
print "\e[2K" # 行全体をクリア
|
|
789
|
+
end
|
|
790
|
+
|
|
791
|
+
# Escキーでキャンセル可能な入力処理
|
|
792
|
+
# 戻り値: 入力された文字列 (Escでキャンセルした場合はnil)
|
|
793
|
+
def read_line_with_escape
|
|
794
|
+
require 'io/console'
|
|
795
|
+
input = []
|
|
796
|
+
|
|
797
|
+
loop do
|
|
798
|
+
char = STDIN.getch
|
|
799
|
+
|
|
800
|
+
case char
|
|
801
|
+
when "\e" # Escape
|
|
802
|
+
# 入力をクリア
|
|
803
|
+
print "\r" + ' ' * (input.length + 50) + "\r"
|
|
804
|
+
return nil
|
|
805
|
+
when "\r", "\n" # Enter
|
|
806
|
+
puts
|
|
807
|
+
return input.join
|
|
808
|
+
when "\u007F", "\b" # Backspace/Delete
|
|
809
|
+
unless input.empty?
|
|
810
|
+
input.pop
|
|
811
|
+
# カーソルを1つ戻して文字を消去
|
|
812
|
+
print "\b \b"
|
|
813
|
+
end
|
|
814
|
+
when "\u0003" # Ctrl+C
|
|
815
|
+
puts
|
|
816
|
+
raise Interrupt
|
|
817
|
+
else
|
|
818
|
+
# 印字可能文字のみ受け付ける
|
|
819
|
+
if char.ord >= ASCII_PRINTABLE_START && char.ord < ASCII_PRINTABLE_END ||
|
|
820
|
+
char.bytesize > MULTIBYTE_THRESHOLD # マルチバイト文字(日本語など)
|
|
821
|
+
input << char
|
|
822
|
+
print char
|
|
823
|
+
end
|
|
824
|
+
end
|
|
825
|
+
end
|
|
826
|
+
end
|
|
827
|
+
end
|
|
828
|
+
end
|